Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
@@ -0,0 +1,30 @@
package httpapi
import (
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/go-chi/chi/v5"
)
// POST /api/admin/settings/ai-roles/{role}/test — probe platform AI role credentials.
// Mirrors mail/test: 200 with status ok|failed|skipped; never echoes secrets or upstream bodies.
func (s *Server) handleAdminTestAIRole(w http.ResponseWriter, r *http.Request) {
if s.AI == nil {
Error(w, http.StatusServiceUnavailable, "ai provider unavailable")
return
}
role := strings.TrimSpace(chi.URLParam(r, "role"))
if role == "" || !platformsettings.ValidAIRole(role) {
Error(w, http.StatusBadRequest, "unknown ai role (want processing|vectorization|docs_api|support)")
return
}
result, err := s.AI.TestPlatformRole(r.Context(), role)
if err != nil {
// Safe message only — never echo provider error bodies (may contain key fragments).
JSON(w, http.StatusOK, result)
return
}
JSON(w, http.StatusOK, result)
}
@@ -0,0 +1,117 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/google/uuid"
)
func TestHandleAdminTestAIRole_unknownRole(t *testing.T) {
t.Parallel()
sm, _, token, s := newAdminAIRoleTestServer(t)
h := s.Router()
csrf := csrfCookieForSession(t, h, sm, token)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/admin/settings/ai-roles/not-a-role/test", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
req.AddCookie(csrf)
req.Header.Set("X-CSRF-Token", csrf.Value)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d want 400 body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleAdminTestAIRole_unconfiguredSkipped(t *testing.T) {
t.Parallel()
sm, _, token, s := newAdminAIRoleTestServer(t)
h := s.Router()
csrf := csrfCookieForSession(t, h, sm, token)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/admin/settings/ai-roles/support/test", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
req.AddCookie(csrf)
req.Header.Set("X-CSRF-Token", csrf.Value)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d want 200 body=%s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v body=%s", err, rec.Body.String())
}
if body["status"] != "skipped" {
t.Fatalf("status=%v want skipped body=%s", body["status"], rec.Body.String())
}
if body["role"] != "support" {
t.Fatalf("role=%v", body["role"])
}
if raw, exists := body["api_key"]; exists && raw != nil && raw != "" {
t.Fatalf("must not leak api_key, got %#v", raw)
}
}
func newAdminAIRoleTestServer(t *testing.T) (*scs.SessionManager, uuid.UUID, string, *Server) {
t.Helper()
sm := scs.New()
sm.Cookie.Name = "descrybe_session"
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{})
ai := aiprovider.NewService(nil, aiprovider.EnvConfig{})
ai.Platform = plat
s := &Server{
Config: config.Config{
CSRFCookieName: "descrybe_csrf",
WebOrigin: "http://localhost:5173",
},
Sessions: sm,
Auth: &auth.Service{},
AI: ai,
PlatformSettings: plat,
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
return got == uid, nil
},
}
return sm, uid, seedAdminSession(t, sm, uid), s
}
func seedAdminSession(t *testing.T, sm *scs.SessionManager, uid uuid.UUID) string {
t.Helper()
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
return c.Value
}
}
t.Fatal("expected session cookie from seed request")
return ""
}
func csrfCookieForSession(t *testing.T, h http.Handler, sm *scs.SessionManager, sessionToken string) *http.Cookie {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: sessionToken})
h.ServeHTTP(rec, req)
if c := findCSRFCookie(rec.Result().Cookies()); c != nil {
return c
}
t.Fatal("expected CSRF cookie")
return nil
}
@@ -0,0 +1,713 @@
package httpapi
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
"github.com/google/uuid"
)
const (
adminAnalyticsDefaultDays = 30
adminAnalyticsMinDays = 7
adminAnalyticsMaxDays = 90
adminAnalyticsTopCompanies = 25
adminAnalyticsRecentCycles = 40
adminAnalyticsProviderDetailMax = 40
adminAnalyticsStuckAfter = 2 * time.Hour
)
func adminAnalyticsSummaryOnly(r *http.Request) bool {
v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("summary")))
if v == "" {
v = strings.TrimSpace(strings.ToLower(r.URL.Query().Get("summary_only")))
}
return v == "1" || v == "true" || v == "yes"
}
type adminDayPoint struct {
Date string `json:"date"`
Tokens int64 `json:"tokens"`
Products int64 `json:"products,omitempty"`
Created int64 `json:"created,omitempty"`
Completed int64 `json:"completed,omitempty"`
Failed int64 `json:"failed,omitempty"`
}
type adminAnalyticsSummary struct {
Users int64 `json:"users"`
Companies int64 `json:"companies"`
UsersPeriod int64 `json:"users_period"`
CompaniesPeriod int64 `json:"companies_period"`
CreditsAllocated int64 `json:"credits_allocated"`
CreditsUsed int64 `json:"credits_used"`
CreditsRemaining int64 `json:"credits_remaining"`
TokensTotal int64 `json:"tokens_total"`
TokensPeriod int64 `json:"tokens_period"`
JobsTotal int64 `json:"jobs_total"`
JobsByStatus map[string]int64 `json:"jobs_by_status"`
JobsStuck int64 `json:"jobs_stuck"`
JobsFailedPeriod int64 `json:"jobs_failed_period"`
JobsCompletedPeriod int64 `json:"jobs_completed_period"`
ProductsProcessed int64 `json:"products_processed"`
ProductsRaw int64 `json:"products_raw"`
FeedsInput int64 `json:"feeds_input"`
FeedsExport int64 `json:"feeds_export"`
ApiKeysActive int64 `json:"api_keys_active"`
ApiKeysTotal int64 `json:"api_keys_total"`
FeedSyncByStatus map[string]int64 `json:"feed_sync_by_status"`
TicketsByStatus map[string]int64 `json:"tickets_by_status"`
}
type adminSignupDayPoint struct {
Date string `json:"date"`
Users int64 `json:"users"`
Companies int64 `json:"companies"`
}
type adminCompanyUsage struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
TotalCredits int64 `json:"total_credits"`
UsedCredits int64 `json:"used_credits"`
Remaining int64 `json:"credits_remaining"`
Tokens int64 `json:"tokens"`
Jobs int64 `json:"jobs"`
Providers adminProviderBreakdown `json:"providers"`
}
type adminProviderDayPoint struct {
Date string `json:"date"`
Internal int64 `json:"internal"`
Popular int64 `json:"popular"`
Custom int64 `json:"custom"`
Unknown int64 `json:"unknown"`
Total int64 `json:"total"`
}
type adminProviderDetail struct {
Provider string `json:"provider"`
Class string `json:"class"`
Tokens int64 `json:"tokens"`
Products int64 `json:"products"`
}
type adminBillingCycleRow struct {
CompanyID uuid.UUID `json:"company_id"`
CompanyName string `json:"company_name"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
CreditsUsed int64 `json:"credits_used"`
ProductsProcessed int64 `json:"products_processed"`
}
// adminProviderBucket is per-mode usage (internal / popular / custom / unknown).
type adminProviderBucket struct {
Tokens int64 `json:"tokens"`
Jobs int64 `json:"jobs"`
Products int64 `json:"products"`
}
type adminProviderBreakdown struct {
Internal adminProviderBucket `json:"internal"`
Popular adminProviderBucket `json:"popular"`
Custom adminProviderBucket `json:"custom"`
Unknown adminProviderBucket `json:"unknown"`
}
// handleAdminAnalytics returns platform-wide aggregates from live tables only.
// GET /api/admin/analytics?days=30
// Optional: summary=1|true — dashboard cards only (skips series/companies/cycles).
func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
days := adminAnalyticsDefaultDays
if raw := r.URL.Query().Get("days"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil {
days = clampAdminAnalyticsDays(n)
}
}
summaryOnly := adminAnalyticsSummaryOnly(r)
ctx := r.Context()
since := time.Now().UTC().Truncate(24*time.Hour).AddDate(0, 0, -(days - 1))
summary, err := s.loadAdminAnalyticsSummary(ctx, since)
if err != nil {
Error(w, http.StatusInternalServerError, "analytics summary failed")
return
}
providers, providerDetail := s.loadAdminProviderBreakdown(ctx)
if summaryOnly {
JSON(w, http.StatusOK, map[string]any{
"days": days,
"summary": summary,
"providers": providers,
})
return
}
tokenByDay := map[string]adminDayPoint{}
tokRows, err := s.Pool.Query(ctx, `
SELECT (created_at AT TIME ZONE 'UTC')::date AS d,
COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint,
COUNT(*)::bigint
FROM processed_products
WHERE created_at >= $1
GROUP BY 1
ORDER BY 1`, since)
if err != nil {
Error(w, http.StatusInternalServerError, "analytics token series failed")
return
}
for tokRows.Next() {
var d time.Time
var tokens, products int64
if err := tokRows.Scan(&d, &tokens, &products); err != nil {
tokRows.Close()
Error(w, http.StatusInternalServerError, "analytics token series scan failed")
return
}
key := d.UTC().Format("2006-01-02")
tokenByDay[key] = adminDayPoint{Date: key, Tokens: tokens, Products: products}
}
tokRows.Close()
if err := tokRows.Err(); err != nil {
Error(w, http.StatusInternalServerError, "analytics token series rows failed")
return
}
jobByDay := map[string]adminDayPoint{}
jSeries, err := s.Pool.Query(ctx, `
SELECT (created_at AT TIME ZONE 'UTC')::date AS d,
COUNT(*)::bigint,
COUNT(*) FILTER (WHERE status = 'completed')::bigint,
COUNT(*) FILTER (WHERE status = 'failed')::bigint,
COALESCE(SUM(estimated_tokens), 0)::bigint
FROM processing_jobs
WHERE created_at >= $1
GROUP BY 1
ORDER BY 1`, since)
if err != nil {
Error(w, http.StatusInternalServerError, "analytics job series failed")
return
}
for jSeries.Next() {
var d time.Time
var created, completed, failed, tokens int64
if err := jSeries.Scan(&d, &created, &completed, &failed, &tokens); err != nil {
jSeries.Close()
Error(w, http.StatusInternalServerError, "analytics job series scan failed")
return
}
key := d.UTC().Format("2006-01-02")
jobByDay[key] = adminDayPoint{
Date: key,
Created: created,
Completed: completed,
Failed: failed,
Tokens: tokens,
}
}
jSeries.Close()
if err := jSeries.Err(); err != nil {
Error(w, http.StatusInternalServerError, "analytics job series rows failed")
return
}
tokenSeries := fillAdminDaySeries(since, days, tokenByDay, func(p adminDayPoint) adminDayPoint {
return adminDayPoint{Date: p.Date, Tokens: p.Tokens, Products: p.Products}
})
jobSeries := fillAdminDaySeries(since, days, jobByDay, func(p adminDayPoint) adminDayPoint {
return adminDayPoint{
Date: p.Date,
Created: p.Created,
Completed: p.Completed,
Failed: p.Failed,
Tokens: p.Tokens,
}
})
companies := make([]adminCompanyUsage, 0, adminAnalyticsTopCompanies)
companyIDs := make([]uuid.UUID, 0, adminAnalyticsTopCompanies)
cRows, err := s.Pool.Query(ctx, `
SELECT c.id, c.name,
COALESCE(cb.total_credits, 0)::bigint,
COALESCE(cb.used_credits, 0)::bigint,
COALESCE(tok.tokens, 0)::bigint,
COALESCE(jobs.cnt, 0)::bigint
FROM companies c
LEFT JOIN credit_balances cb ON cb.company_id = c.id
LEFT JOIN (
SELECT company_id, SUM(COALESCE(total_tokens, 0))::bigint AS tokens
FROM processed_products
GROUP BY company_id
) tok ON tok.company_id = c.id
LEFT JOIN (
SELECT company_id, COUNT(*)::bigint AS cnt
FROM processing_jobs
GROUP BY company_id
) jobs ON jobs.company_id = c.id
ORDER BY COALESCE(tok.tokens, 0) DESC, COALESCE(cb.used_credits, 0) DESC, c.name ASC
LIMIT $1`, adminAnalyticsTopCompanies)
if err != nil {
Error(w, http.StatusInternalServerError, "analytics companies usage failed")
return
}
for cRows.Next() {
var row adminCompanyUsage
if err := cRows.Scan(&row.ID, &row.Name, &row.TotalCredits, &row.UsedCredits, &row.Tokens, &row.Jobs); err != nil {
cRows.Close()
Error(w, http.StatusInternalServerError, "analytics companies scan failed")
return
}
row.Remaining = row.TotalCredits - row.UsedCredits
if row.Remaining < 0 {
row.Remaining = 0
}
companies = append(companies, row)
companyIDs = append(companyIDs, row.ID)
}
cRows.Close()
if err := cRows.Err(); err != nil {
Error(w, http.StatusInternalServerError, "analytics companies rows failed")
return
}
cycles := make([]adminBillingCycleRow, 0, adminAnalyticsRecentCycles)
cyRows, err := s.Pool.Query(ctx, `
SELECT bc.company_id, c.name, bc.start_date, bc.end_date,
bc.credits_used::bigint, bc.products_processed::bigint
FROM billing_cycles bc
JOIN companies c ON c.id = bc.company_id
ORDER BY bc.start_date DESC
LIMIT $1`, adminAnalyticsRecentCycles)
if err != nil {
Error(w, http.StatusInternalServerError, "analytics billing cycles failed")
return
}
for cyRows.Next() {
var row adminBillingCycleRow
if err := cyRows.Scan(&row.CompanyID, &row.CompanyName, &row.StartDate, &row.EndDate, &row.CreditsUsed, &row.ProductsProcessed); err != nil {
cyRows.Close()
Error(w, http.StatusInternalServerError, "analytics billing cycles scan failed")
return
}
cycles = append(cycles, row)
}
cyRows.Close()
if err := cyRows.Err(); err != nil {
Error(w, http.StatusInternalServerError, "analytics billing cycles rows failed")
return
}
providerDaySeries := s.loadAdminProviderDaySeries(ctx, since, days)
signupSeries := s.loadAdminSignupDaySeries(ctx, since, days)
companyProviders := s.loadAdminCompanyProviderBreakdown(ctx, companyIDs)
for i := range companies {
if split, ok := companyProviders[companies[i].ID]; ok {
companies[i].Providers = split
}
}
JSON(w, http.StatusOK, map[string]any{
"days": days,
"summary": summary,
"series": map[string]any{
"tokens_by_day": tokenSeries,
"jobs_by_day": jobSeries,
"tokens_by_provider_day": providerDaySeries,
"signups_by_day": signupSeries,
},
"companies": companies,
"billing_cycles": cycles,
"providers": providers,
"tokens_by_provider_detail": providerDetail,
"notes": []string{
"Tokens come from processed_products.total_tokens (LLM usage recorded per product).",
"Provider classes use processed_products.ai_provider_mode: internal | popular:<name> | custom (blank/unknown rolled into the internal card).",
"Job tokens use processing_jobs.estimated_tokens (running tally during jobs).",
"Jobs stuck = status running with updated_at older than 2 hours (same threshold as diagnostics).",
"Credits are live credit_balances snapshots - daily credit debits are not ledgered yet.",
"Feeds / feed sync / support tickets / API keys are live table aggregates.",
"Billing cycle rows are historical rollups when cycles have been run (may lag the active company_plans window).",
"Use /admin/diagnostics for queue health checks and recent failure samples.",
},
})
}
func (s *Server) loadAdminAnalyticsSummary(ctx context.Context, since time.Time) (adminAnalyticsSummary, error) {
summary := adminAnalyticsSummary{
JobsByStatus: map[string]int64{},
FeedSyncByStatus: map[string]int64{},
TicketsByStatus: map[string]int64{},
}
err := s.Pool.QueryRow(ctx, `
SELECT
(SELECT COUNT(*)::bigint FROM users),
(SELECT COUNT(*)::bigint FROM companies),
(SELECT COUNT(*)::bigint FROM users WHERE created_at >= $1),
(SELECT COUNT(*)::bigint FROM companies WHERE created_at >= $1),
(SELECT COALESCE(SUM(total_credits), 0)::bigint FROM credit_balances),
(SELECT COALESCE(SUM(used_credits), 0)::bigint FROM credit_balances),
(SELECT COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint FROM processed_products),
(SELECT COUNT(*)::bigint FROM processed_products),
(SELECT COUNT(*)::bigint FROM raw_products),
(SELECT COUNT(*)::bigint FROM input_feeds),
(SELECT COUNT(*)::bigint FROM export_feeds),
(SELECT COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint FROM processed_products WHERE created_at >= $1),
(SELECT COUNT(*)::bigint FROM processing_jobs WHERE created_at >= $1 AND status = 'failed'),
(SELECT COUNT(*)::bigint FROM processing_jobs WHERE created_at >= $1 AND status = 'completed'),
(SELECT COUNT(*)::bigint FROM processing_jobs
WHERE status = 'running' AND updated_at < now() - make_interval(secs => $2)),
(SELECT COUNT(*)::bigint FROM api_keys),
(SELECT COUNT(*)::bigint FROM api_keys WHERE revoked_at IS NULL)
`, since, adminAnalyticsStuckAfter.Seconds()).Scan(
&summary.Users,
&summary.Companies,
&summary.UsersPeriod,
&summary.CompaniesPeriod,
&summary.CreditsAllocated,
&summary.CreditsUsed,
&summary.TokensTotal,
&summary.ProductsProcessed,
&summary.ProductsRaw,
&summary.FeedsInput,
&summary.FeedsExport,
&summary.TokensPeriod,
&summary.JobsFailedPeriod,
&summary.JobsCompletedPeriod,
&summary.JobsStuck,
&summary.ApiKeysTotal,
&summary.ApiKeysActive,
)
if err != nil {
return summary, err
}
summary.CreditsRemaining = summary.CreditsAllocated - summary.CreditsUsed
if summary.CreditsRemaining < 0 {
summary.CreditsRemaining = 0
}
jobRows, err := s.Pool.Query(ctx, `
SELECT status, COUNT(*)
FROM processing_jobs
GROUP BY status`)
if err != nil {
return summary, err
}
defer jobRows.Close()
for jobRows.Next() {
var status string
var n int64
if err := jobRows.Scan(&status, &n); err != nil {
return summary, err
}
summary.JobsByStatus[status] = n
summary.JobsTotal += n
}
if err := jobRows.Err(); err != nil {
return summary, err
}
summary.FeedSyncByStatus = s.loadAdminStatusCounts(ctx,
`SELECT status, COUNT(*)::bigint FROM feed_sync_jobs GROUP BY status`)
summary.TicketsByStatus = s.loadAdminStatusCounts(ctx,
`SELECT status, COUNT(*)::bigint FROM support_tickets GROUP BY status`)
return summary, nil
}
func (s *Server) loadAdminStatusCounts(ctx context.Context, query string) map[string]int64 {
out := map[string]int64{}
rows, err := s.Pool.Query(ctx, query)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var status string
var n int64
if err := rows.Scan(&status, &n); err != nil {
return out
}
out[status] = n
}
return out
}
func (s *Server) loadAdminSignupDaySeries(ctx context.Context, since time.Time, days int) []adminSignupDayPoint {
byDay := map[string]adminSignupDayPoint{}
uRows, err := s.Pool.Query(ctx, `
SELECT (created_at AT TIME ZONE 'UTC')::date AS d, COUNT(*)::bigint
FROM users WHERE created_at >= $1
GROUP BY 1 ORDER BY 1`, since)
if err == nil {
for uRows.Next() {
var d time.Time
var n int64
if err := uRows.Scan(&d, &n); err != nil {
break
}
key := d.UTC().Format("2006-01-02")
pt := byDay[key]
pt.Date = key
pt.Users = n
byDay[key] = pt
}
uRows.Close()
}
cRows, err := s.Pool.Query(ctx, `
SELECT (created_at AT TIME ZONE 'UTC')::date AS d, COUNT(*)::bigint
FROM companies WHERE created_at >= $1
GROUP BY 1 ORDER BY 1`, since)
if err == nil {
for cRows.Next() {
var d time.Time
var n int64
if err := cRows.Scan(&d, &n); err != nil {
break
}
key := d.UTC().Format("2006-01-02")
pt := byDay[key]
pt.Date = key
pt.Companies = n
byDay[key] = pt
}
cRows.Close()
}
out := make([]adminSignupDayPoint, 0, days)
for i := 0; i < days; i++ {
d := since.AddDate(0, 0, i).UTC().Format("2006-01-02")
if p, ok := byDay[d]; ok {
p.Date = d
out = append(out, p)
continue
}
out = append(out, adminSignupDayPoint{Date: d})
}
return out
}
func (s *Server) loadAdminProviderBreakdown(ctx context.Context) (adminProviderBreakdown, []adminProviderDetail) {
out := adminProviderBreakdown{}
detail := make([]adminProviderDetail, 0)
rows, err := s.Pool.Query(ctx, `
SELECT COALESCE(NULLIF(TRIM(ai_provider_mode), ''), 'unknown') AS mode,
COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint,
COUNT(*)::bigint
FROM processed_products
GROUP BY 1
ORDER BY 2 DESC`)
if err != nil {
return out, detail
}
defer rows.Close()
for rows.Next() {
var mode string
var tokens, products int64
if err := rows.Scan(&mode, &tokens, &products); err != nil {
return out, detail
}
class := aiprovider.AnalyticsClass(mode)
bucket := adminProviderBucket{Tokens: tokens, Products: products}
switch class {
case aiprovider.ModePopular:
out.Popular.Tokens += bucket.Tokens
out.Popular.Products += bucket.Products
case aiprovider.ModeCustom:
out.Custom.Tokens += bucket.Tokens
out.Custom.Products += bucket.Products
default:
// internal + unknown → internal card (legacy/backfill)
out.Internal.Tokens += bucket.Tokens
out.Internal.Products += bucket.Products
}
detail = append(detail, adminProviderDetail{
Provider: aiprovider.NormalizeAnalyticsMode(mode),
Class: class,
Tokens: tokens,
Products: products,
})
}
jobRows, err := s.Pool.Query(ctx, `
SELECT COALESCE(NULLIF(TRIM(ai_provider_mode), ''), 'unknown') AS mode,
COUNT(*)::bigint
FROM processing_jobs
GROUP BY 1`)
if err != nil {
return out, detail
}
defer jobRows.Close()
for jobRows.Next() {
var mode string
var jobs int64
if err := jobRows.Scan(&mode, &jobs); err != nil {
return out, detail
}
switch aiprovider.AnalyticsClass(mode) {
case aiprovider.ModePopular:
out.Popular.Jobs += jobs
case aiprovider.ModeCustom:
out.Custom.Jobs += jobs
default:
out.Internal.Jobs += jobs
}
}
if len(detail) > adminAnalyticsProviderDetailMax {
detail = detail[:adminAnalyticsProviderDetailMax]
}
return out, detail
}
func (s *Server) loadAdminProviderDaySeries(ctx context.Context, since time.Time, days int) []adminProviderDayPoint {
byDay := map[string]adminProviderDayPoint{}
rows, err := s.Pool.Query(ctx, `
SELECT (created_at AT TIME ZONE 'UTC')::date AS d,
COALESCE(NULLIF(TRIM(ai_provider_mode), ''), 'unknown') AS mode,
COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint
FROM processed_products
WHERE created_at >= $1
GROUP BY 1, 2
ORDER BY 1`, since)
if err == nil {
defer rows.Close()
for rows.Next() {
var d time.Time
var mode string
var tokens int64
if err := rows.Scan(&d, &mode, &tokens); err != nil {
break
}
key := d.UTC().Format("2006-01-02")
pt := byDay[key]
pt.Date = key
switch aiprovider.AnalyticsClass(mode) {
case aiprovider.ModePopular:
pt.Popular += tokens
case aiprovider.ModeCustom:
pt.Custom += tokens
case "unknown":
pt.Unknown += tokens
default:
pt.Internal += tokens
}
pt.Total += tokens
byDay[key] = pt
}
}
out := make([]adminProviderDayPoint, 0, days)
for i := 0; i < days; i++ {
d := since.AddDate(0, 0, i).UTC().Format("2006-01-02")
if p, ok := byDay[d]; ok {
p.Date = d
out = append(out, p)
continue
}
out = append(out, adminProviderDayPoint{Date: d})
}
return out
}
func (s *Server) loadAdminCompanyProviderBreakdown(ctx context.Context, companyIDs []uuid.UUID) map[uuid.UUID]adminProviderBreakdown {
out := map[uuid.UUID]adminProviderBreakdown{}
if len(companyIDs) == 0 {
return out
}
rows, err := s.Pool.Query(ctx, `
SELECT company_id,
COALESCE(NULLIF(TRIM(ai_provider_mode), ''), 'unknown') AS mode,
COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint,
COUNT(*)::bigint
FROM processed_products
WHERE company_id = ANY($1)
GROUP BY company_id, 2`, companyIDs)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var companyID uuid.UUID
var mode string
var tokens, products int64
if err := rows.Scan(&companyID, &mode, &tokens, &products); err != nil {
return out
}
b := out[companyID]
switch aiprovider.AnalyticsClass(mode) {
case aiprovider.ModePopular:
b.Popular.Tokens += tokens
b.Popular.Products += products
case aiprovider.ModeCustom:
b.Custom.Tokens += tokens
b.Custom.Products += products
default:
b.Internal.Tokens += tokens
b.Internal.Products += products
}
out[companyID] = b
}
jobRows, err := s.Pool.Query(ctx, `
SELECT company_id,
COALESCE(NULLIF(TRIM(ai_provider_mode), ''), 'unknown') AS mode,
COUNT(*)::bigint
FROM processing_jobs
WHERE company_id = ANY($1)
GROUP BY company_id, 2`, companyIDs)
if err != nil {
return out
}
defer jobRows.Close()
for jobRows.Next() {
var companyID uuid.UUID
var mode string
var jobs int64
if err := jobRows.Scan(&companyID, &mode, &jobs); err != nil {
return out
}
b := out[companyID]
switch aiprovider.AnalyticsClass(mode) {
case aiprovider.ModePopular:
b.Popular.Jobs += jobs
case aiprovider.ModeCustom:
b.Custom.Jobs += jobs
default:
b.Internal.Jobs += jobs
}
out[companyID] = b
}
return out
}
func clampAdminAnalyticsDays(n int) int {
if n < adminAnalyticsMinDays {
return adminAnalyticsMinDays
}
if n > adminAnalyticsMaxDays {
return adminAnalyticsMaxDays
}
return n
}
func fillAdminDaySeries(
since time.Time,
days int,
src map[string]adminDayPoint,
mapPoint func(adminDayPoint) adminDayPoint,
) []adminDayPoint {
out := make([]adminDayPoint, 0, days)
for i := 0; i < days; i++ {
d := since.AddDate(0, 0, i).UTC().Format("2006-01-02")
if p, ok := src[d]; ok {
p.Date = d
out = append(out, mapPoint(p))
continue
}
out = append(out, mapPoint(adminDayPoint{Date: d}))
}
return out
}
@@ -0,0 +1,69 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestAdminAnalyticsSummaryOnly(t *testing.T) {
t.Parallel()
cases := []struct {
raw string
want bool
}{
{"", false},
{"summary=0", false},
{"summary=1", true},
{"summary=true", true},
{"summary_only=yes", true},
{"summary_only=no", false},
}
for _, c := range cases {
req := httptest.NewRequest(http.MethodGet, "/api/admin/analytics?"+c.raw, nil)
if got := adminAnalyticsSummaryOnly(req); got != c.want {
t.Fatalf("%q: got %v want %v", c.raw, got, c.want)
}
}
}
func TestClampAdminAnalyticsDays(t *testing.T) {
t.Parallel()
cases := []struct {
in, want int
}{
{0, adminAnalyticsMinDays},
{3, adminAnalyticsMinDays},
{7, 7},
{30, 30},
{90, 90},
{120, adminAnalyticsMaxDays},
}
for _, c := range cases {
if got := clampAdminAnalyticsDays(c.in); got != c.want {
t.Fatalf("clamp(%d)=%d want %d", c.in, got, c.want)
}
}
}
func TestFillAdminDaySeries(t *testing.T) {
t.Parallel()
since := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
src := map[string]adminDayPoint{
"2026-08-01": {Date: "2026-08-01", Tokens: 10, Products: 2},
"2026-08-03": {Date: "2026-08-03", Tokens: 5, Products: 1},
}
out := fillAdminDaySeries(since, 3, src, func(p adminDayPoint) adminDayPoint {
return adminDayPoint{Date: p.Date, Tokens: p.Tokens, Products: p.Products}
})
if len(out) != 3 {
t.Fatalf("len=%d", len(out))
}
if out[0].Tokens != 10 || out[1].Tokens != 0 || out[2].Tokens != 5 {
t.Fatalf("unexpected series: %+v", out)
}
if out[1].Date != "2026-08-02" {
t.Fatalf("gap date=%s", out[1].Date)
}
}
@@ -0,0 +1,271 @@
package httpapi
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/google/uuid"
)
func TestMemberForbiddenOnSensitiveMutations(t *testing.T) {
t.Parallel()
s := &Server{}
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "member")
cases := []struct {
name string
fn http.HandlerFunc
body string
}{
{name: "create_api_key", fn: s.handleCreateAPIKey, body: `{"name":"x"}`},
{name: "revoke_api_key", fn: s.handleRevokeAPIKey, body: ""},
{name: "put_email", fn: s.handlePutEmailIntegration, body: `{}`},
{name: "verify_email", fn: s.handleVerifyEmailIntegration, body: ""},
{name: "test_email", fn: s.handleTestEmailIntegration, body: `{}`},
{name: "send_email", fn: s.handleSendEmail, body: `{}`},
{name: "put_ai", fn: s.handlePutAIIntegration, body: `{}`},
{name: "test_ai", fn: s.handleTestAIIntegration, body: ""},
{name: "update_woo", fn: s.handleUpdateWooConfig, body: `{}`},
{name: "update_woo_maps", fn: s.handleUpdateWooMaps, body: `{}`},
{name: "update_woo_schedule", fn: s.handleUpdateWooSchedule, body: `{}`},
{name: "update_shopify", fn: s.handleUpdateShopifyConfig, body: `{}`},
{name: "update_shopify_schedule", fn: s.handleUpdateShopifySchedule, body: `{}`},
{name: "stripe_checkout", fn: s.handleStripeCheckout, body: `{}`},
{name: "stripe_portal", fn: s.handleStripePortal, body: `{}`},
{name: "reset_products", fn: s.handleResetProducts, body: `{"product_ids":[],"kind":"raw"}`},
{name: "import_csv", fn: s.handleImportCSV, body: ""},
{name: "put_category_attributes", fn: s.handlePutCategoryAttributes, body: `{"attribute_ids":[]}`},
{name: "delete_category", fn: s.handleDeleteCategory, body: ""},
{name: "delete_attribute", fn: s.handleDeleteAttribute, body: ""},
{name: "delete_feed", fn: s.handleDeleteFeed, body: ""},
{name: "delete_export_feed", fn: s.handleDeleteExportFeed, body: ""},
{name: "rotate_export_feed_token", fn: s.handleRotateExportFeedPublicToken, body: ""},
{name: "delete_file", fn: s.handleDeleteFile, body: ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(tc.body))
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
tc.fn(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d body=%s, want 403", rec.Code, rec.Body.String())
}
})
}
}
func TestCompanyAdminAllowedRoles(t *testing.T) {
t.Parallel()
if !CompanyAdminAllowed(context.WithValue(context.Background(), ctxRole, "admin")) {
t.Fatal("admin role should be allowed")
}
if !CompanyAdminAllowed(context.WithValue(context.Background(), ctxRole, "api")) {
t.Fatal("api role should be allowed for catalog destructive ops")
}
if CompanyAdminAllowed(context.WithValue(context.Background(), ctxRole, "member")) {
t.Fatal("member role must not be allowed")
}
if CompanyAdminAllowed(context.Background()) {
t.Fatal("missing role must not be allowed")
}
}
func TestAPIKeyContextRole(t *testing.T) {
t.Parallel()
if got := apiKeyContextRole("admin"); got != "api" {
t.Fatalf("admin -> api, got %q", got)
}
if got := apiKeyContextRole("Admin"); got != "api" {
t.Fatalf("Admin -> api, got %q", got)
}
if got := apiKeyContextRole("member"); got != "member" {
t.Fatalf("member stays member, got %q", got)
}
if got := apiKeyContextRole(""); got != "member" {
t.Fatalf("empty normalizes to member, got %q", got)
}
if CompanyAdminAllowed(context.WithValue(context.Background(), ctxRole, apiKeyContextRole("member"))) {
t.Fatal("member-owned API key must not pass CompanyAdminAllowed")
}
if !CompanyAdminAllowed(context.WithValue(context.Background(), ctxRole, apiKeyContextRole("admin"))) {
t.Fatal("admin-owned API key must pass CompanyAdminAllowed")
}
}
func TestRequirePlatformAdminUnauthorized(t *testing.T) {
t.Parallel()
s := &Server{}
called := false
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if called {
t.Fatal("handler must not run without session user")
}
}
func TestRequirePlatformAdminForbiddenAndAllow(t *testing.T) {
t.Parallel()
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
t.Run("forbidden", func(t *testing.T) {
t.Parallel()
s := &Server{
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
if got != uid {
t.Fatalf("userID = %s, want %s", got, uid)
}
return false, nil
},
}
called := false
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if called {
t.Fatal("handler must not run for non-admin")
}
})
t.Run("db_error_fail_closed", func(t *testing.T) {
t.Parallel()
s := &Server{
testPlatformAdmin: func(context.Context, uuid.UUID) (bool, error) {
return false, context.DeadlineExceeded
},
}
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 on lookup error", rec.Code)
}
})
t.Run("allow", func(t *testing.T) {
t.Parallel()
s := &Server{
testPlatformAdmin: func(context.Context, uuid.UUID) (bool, error) {
return true, nil
},
}
called := false
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
if !called {
t.Fatal("handler must run for platform admin")
}
})
t.Run("support_staff_forbidden", func(t *testing.T) {
t.Parallel()
s := &Server{
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
return auth.ResolveStaffAccess(true, auth.StaffRoleSupportStaff), nil
},
}
called := false
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodGet, "/api/admin/plans", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if called {
t.Fatal("support_staff must not reach full admin routes")
}
})
}
func TestRequireSupportDesk(t *testing.T) {
t.Parallel()
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
t.Run("support_staff_allowed", func(t *testing.T) {
t.Parallel()
s := &Server{
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
return auth.ResolveStaffAccess(false, auth.StaffRoleSupportStaff), nil
},
}
called := false
h := s.RequireSupportDesk(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodGet, "/api/admin/support/tickets", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent || !called {
t.Fatalf("status=%d called=%v", rec.Code, called)
}
})
t.Run("plain_user_forbidden", func(t *testing.T) {
t.Parallel()
s := &Server{
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
return auth.StaffAccess{}, nil
},
}
h := s.RequireSupportDesk(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodGet, "/api/admin/support/tickets", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
})
}
@@ -0,0 +1,31 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestQueryTruthyWithoutActivePlan(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/admin/companies?without_active_plan=1", nil)
if !QueryTruthy(req, "without_active_plan") {
t.Fatal("expected without_active_plan=1 to be truthy")
}
req = httptest.NewRequest(http.MethodGet, "/api/admin/companies", nil)
if QueryTruthy(req, "without_active_plan") {
t.Fatal("expected missing flag to be false")
}
}
func TestQueryTruthyWithoutAPIKeys(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/admin/companies?without_api_keys=1", nil)
if !QueryTruthy(req, "without_api_keys") {
t.Fatal("expected without_api_keys=1 to be truthy")
}
req = httptest.NewRequest(http.MethodGet, "/api/admin/companies", nil)
if QueryTruthy(req, "without_api_keys") {
t.Fatal("expected missing without_api_keys to be false")
}
}
@@ -0,0 +1,501 @@
package httpapi
import (
"context"
"errors"
"net/http"
"sort"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
const defaultDevPassword = "DemoPass123!"
func isLocalDemoEmail(email string) bool {
switch strings.ToLower(strings.TrimSpace(email)) {
case "demo@descrybe.local", "demo@descrybe.test":
return true
default:
return false
}
}
// resolveDevImpersonationActor returns the privileged actor allowed to drive non-prod
// user switching: the current full admin/demo user, or the stored impersonator.
func (s *Server) resolveDevImpersonationActor(ctx context.Context) (actorID uuid.UUID, ok bool, err error) {
if s.Config.IsProduction() {
return uuid.Nil, false, nil
}
uid, hasUID := UserIDFromContext(ctx)
if !hasUID || uid == uuid.Nil {
return uuid.Nil, false, nil
}
if s.Auth == nil {
return uuid.Nil, false, errors.New("auth unavailable")
}
access, err := s.checkStaffAccess(ctx, uid)
if err != nil {
return uuid.Nil, false, err
}
if access.FullAdmin {
return uid, true, nil
}
user, err := s.Auth.GetUser(ctx, uid)
if err == nil && isLocalDemoEmail(user.Email) {
return uid, true, nil
}
impStr := strings.TrimSpace(s.Sessions.GetString(ctx, auth.SessionImpersonatorIDKey))
if impStr == "" {
return uuid.Nil, false, nil
}
impID, err := uuid.Parse(impStr)
if err != nil || impID == uuid.Nil {
return uuid.Nil, false, nil
}
impAccess, err := s.checkStaffAccess(ctx, impID)
if err != nil {
return uuid.Nil, false, err
}
if impAccess.FullAdmin {
return impID, true, nil
}
impUser, err := s.Auth.GetUser(ctx, impID)
if err == nil && isLocalDemoEmail(impUser.Email) {
return impID, true, nil
}
return uuid.Nil, false, nil
}
// handleAdminDevSetPassword sets a known local password for any active user.
// Blocked in production. Intended for @legacy.local migrated accounts (invite emails skip those).
func (s *Server) handleAdminDevSetPassword(w http.ResponseWriter, r *http.Request) {
if s.Config.IsProduction() {
Error(w, http.StatusNotFound, "not found")
return
}
if s.Auth == nil {
Error(w, http.StatusServiceUnavailable, "auth unavailable")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
Password string `json:"password"`
}
_ = DecodeJSONOptional(r, &body)
password := body.Password
if strings.TrimSpace(password) == "" {
password = defaultDevPassword
}
if len(password) < 8 {
Error(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
user, err := s.Auth.GetUser(r.Context(), id)
if err != nil {
Error(w, http.StatusNotFound, "user not found")
return
}
if !user.IsActive {
Error(w, http.StatusBadRequest, "user is inactive")
return
}
if err := s.Auth.ForceSetPassword(r.Context(), id, password); err != nil {
if errors.Is(err, auth.ErrUserNotFound) {
Error(w, http.StatusNotFound, "user not found")
return
}
LogAndError(w, http.StatusInternalServerError, "could not set password", err)
return
}
JSON(w, http.StatusOK, map[string]any{
"ok": true,
"user_id": id,
"email": user.Email,
"hint": "Password set for local login. Omit body.password to use the built-in local default.",
})
}
// handleAdminDevImpersonate swaps the current session to the target user (non-production only).
func (s *Server) handleAdminDevImpersonate(w http.ResponseWriter, r *http.Request) {
if s.Config.IsProduction() {
Error(w, http.StatusNotFound, "not found")
return
}
if s.Auth == nil {
Error(w, http.StatusServiceUnavailable, "auth unavailable")
return
}
actorID, allowed, err := s.resolveDevImpersonationActor(r.Context())
if err != nil {
LogAndError(w, http.StatusInternalServerError, "could not authorize user switch", err)
return
}
if !allowed {
Error(w, http.StatusForbidden, "user switch not allowed")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
adminID, ok := UserIDFromContext(r.Context())
if !ok || adminID == uuid.Nil {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
if adminID == id {
Error(w, http.StatusBadRequest, "already signed in as this user")
return
}
user, err := s.Auth.GetUser(r.Context(), id)
if err != nil {
Error(w, http.StatusNotFound, "user not found")
return
}
if !user.IsActive {
Error(w, http.StatusBadRequest, "user is inactive")
return
}
companies, err := s.Auth.ListUserCompanies(r.Context(), id)
if err != nil {
LogAndError(w, http.StatusInternalServerError, "could not list companies", err)
return
}
var companyID uuid.UUID
if len(companies) > 0 {
companyID = companies[0].ID
}
if err := s.beginImpersonatedSession(r.Context(), id, companyID, actorID); err != nil {
Error(w, http.StatusInternalServerError, "session start failed")
return
}
JSON(w, http.StatusOK, map[string]any{
"ok": true,
"user": user,
"company_id": companyID,
"companies": companies,
"hint": "Session switched. Reload the app to view this user's tenant context.",
})
}
// handleAdminDevStopImpersonate restores the session to the original admin/demo actor.
func (s *Server) handleAdminDevStopImpersonate(w http.ResponseWriter, r *http.Request) {
if s.Config.IsProduction() {
Error(w, http.StatusNotFound, "not found")
return
}
if s.Auth == nil {
Error(w, http.StatusServiceUnavailable, "auth unavailable")
return
}
actorID, allowed, err := s.resolveDevImpersonationActor(r.Context())
if err != nil {
LogAndError(w, http.StatusInternalServerError, "could not authorize stop impersonate", err)
return
}
if !allowed {
Error(w, http.StatusForbidden, "user switch not allowed")
return
}
impStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionImpersonatorIDKey))
if impStr == "" {
Error(w, http.StatusBadRequest, "not impersonating")
return
}
impID, err := uuid.Parse(impStr)
if err != nil || impID == uuid.Nil {
Error(w, http.StatusBadRequest, "invalid impersonator")
return
}
if impID != actorID {
// Prefer the stored impersonator when it is still the privileged actor.
impAccess, aerr := s.checkStaffAccess(r.Context(), impID)
if aerr != nil || !impAccess.FullAdmin {
impUser, uerr := s.Auth.GetUser(r.Context(), impID)
if uerr != nil || !isLocalDemoEmail(impUser.Email) {
Error(w, http.StatusForbidden, "user switch not allowed")
return
}
}
}
user, err := s.Auth.GetUser(r.Context(), impID)
if err != nil {
Error(w, http.StatusNotFound, "impersonator not found")
return
}
if !user.IsActive {
Error(w, http.StatusBadRequest, "impersonator is inactive")
return
}
companies, err := s.Auth.ListUserCompanies(r.Context(), impID)
if err != nil {
LogAndError(w, http.StatusInternalServerError, "could not list companies", err)
return
}
var companyID uuid.UUID
if len(companies) > 0 {
companyID = companies[0].ID
}
// Clear impersonation then start a normal session as the actor.
s.Sessions.Remove(r.Context(), auth.SessionImpersonatorIDKey)
if err := s.beginAuthenticatedSession(r.Context(), impID, companyID); err != nil {
Error(w, http.StatusInternalServerError, "session start failed")
return
}
JSON(w, http.StatusOK, map[string]any{
"ok": true,
"user": user,
"company_id": companyID,
"companies": companies,
"hint": "Returned to original session. Reload the app.",
})
}
// primaryA1LegacyUserID is the Clerk user_id for the A1 contact we care about in local demos
// (migrated as …@legacy.local). Used only for non-prod switcher labels.
const primaryA1LegacyUserID = "user_30AqqJ8uepxvPUzDSqy81U5w6Ll"
type switchableUserRow struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Name *string `json:"name"`
LegacyUserID *string `json:"legacy_user_id,omitempty"`
MembershipRole string `json:"membership_role,omitempty"`
CompanyID uuid.UUID `json:"company_id"`
CompanyName string `json:"company_name"`
CompanyLabel string `json:"company_label"`
Label string `json:"label"`
Subtitle string `json:"subtitle"`
IsDemoAdmin bool `json:"is_demo_admin"`
IsPrimaryA1 bool `json:"is_primary_a1"`
ClerkSuffix string `json:"clerk_suffix,omitempty"`
}
func companyDisplayLabel(companyName, legacyCompanyID string) string {
name := strings.TrimSpace(companyName)
if isA1LegacyCompany(legacyCompanyID, name) {
// Prefer live company name when already A1 Slovenija; never fake "Local Demo Co".
if name != "" && !strings.EqualFold(name, "Local Demo Co") {
return name
}
return "A1 Slovenija"
}
if name == "" {
return "Unknown company"
}
return name
}
func clerkIDFromLegacy(email string, legacyUserID *string) string {
if legacyUserID != nil {
if id := strings.TrimSpace(*legacyUserID); id != "" {
return id
}
}
email = strings.TrimSpace(strings.ToLower(email))
if strings.HasSuffix(email, "@legacy.local") {
return strings.TrimSuffix(email, "@legacy.local")
}
return ""
}
func shortClerkSuffix(clerkID string) string {
id := strings.TrimSpace(clerkID)
if id == "" {
return ""
}
const n = 8
if len(id) <= n {
return id
}
return id[len(id)-n:]
}
func isPrimaryA1User(email, clerkID string) bool {
emailNorm := strings.TrimSpace(strings.ToLower(email))
if emailNorm == "a1-primary@descrybe.local" {
return true
}
if strings.EqualFold(strings.TrimSpace(clerkID), primaryA1LegacyUserID) {
return true
}
target := strings.ToLower(primaryA1LegacyUserID)
local := emailNorm
if i := strings.IndexByte(local, '@'); i > 0 {
local = local[:i]
}
return local == target
}
// isA1LegacyCompany is true when the membership company maps to MySQL A1 Slovenija
// (legacy_company_id 97e1a309-…, dump name, or the old Local Demo Co rename).
// isA1LegacyCompany is true for non-prod switcher labels when the membership
// company maps to migrated A1 (immutable legacy_company_id) OR known dump/demo
// display names. Name matches are UI-only — billing cohort uses IsA1CohortCompany.
func isA1LegacyCompany(legacyCompanyID, companyName string) bool {
if billing.IsA1CohortCompany(legacyCompanyID, companyName) {
return true
}
n := strings.TrimSpace(companyName)
return strings.EqualFold(n, "A1 Slovenija") ||
strings.EqualFold(n, "Local Demo Co") ||
strings.EqualFold(n, "A1")
}
// a1SwitcherLabel builds dump-truth labels. MySQL profiles have no human names/emails —
// only Clerk user_id — so we show "A1 · …<clerkSuffix>".
func a1SwitcherLabel(clerkSuffix string) string {
if strings.TrimSpace(clerkSuffix) != "" {
return "A1 · …" + clerkSuffix
}
return "A1 · A1 Slovenija"
}
func enrichSwitchableUser(u *switchableUserRow, legacyCompanyID string) {
u.CompanyLabel = companyDisplayLabel(u.CompanyName, legacyCompanyID)
clerkID := clerkIDFromLegacy(u.Email, u.LegacyUserID)
u.ClerkSuffix = shortClerkSuffix(clerkID)
u.IsDemoAdmin = isLocalDemoEmail(u.Email)
onA1 := isA1LegacyCompany(legacyCompanyID, u.CompanyName)
u.IsPrimaryA1 = onA1 && isPrimaryA1User(u.Email, clerkID)
switch {
case u.IsDemoAdmin:
u.Label = "Demo admin"
u.Subtitle = u.Email
case onA1 && (u.IsPrimaryA1 || clerkID != ""):
// Dump-confirmed A1 members (no human name in MySQL profiles/admin_users).
u.Label = a1SwitcherLabel(u.ClerkSuffix)
if clerkID != "" {
u.Subtitle = "A1 Slovenija · " + clerkID
} else {
u.Subtitle = "A1 Slovenija · " + u.Email
}
default:
if u.Name != nil && strings.TrimSpace(*u.Name) != "" {
u.Label = strings.TrimSpace(*u.Name)
} else {
u.Label = u.Email
}
u.Subtitle = u.Email
}
}
// handleAdminDevListSwitchableUsers lists active users with a preferred company label
// for the header user-switch dropdown (non-production only).
func (s *Server) handleAdminDevListSwitchableUsers(w http.ResponseWriter, r *http.Request) {
if s.Config.IsProduction() {
Error(w, http.StatusNotFound, "not found")
return
}
if s.Pool == nil {
Error(w, http.StatusServiceUnavailable, "database unavailable")
return
}
_, allowed, err := s.resolveDevImpersonationActor(r.Context())
if err != nil {
LogAndError(w, http.StatusInternalServerError, "could not authorize user list", err)
return
}
if !allowed {
Error(w, http.StatusForbidden, "user switch not allowed")
return
}
activeCompanyID := uuid.Nil
if cidStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey)); cidStr != "" {
if cid, err := uuid.Parse(cidStr); err == nil {
activeCompanyID = cid
}
}
rows, err := s.Pool.Query(r.Context(), `
SELECT DISTINCT ON (u.id)
u.id, u.email, u.name, u.legacy_user_id, m.role, c.id, c.name, COALESCE(c.legacy_company_id, '')
FROM users u
INNER JOIN memberships m ON m.user_id = u.id AND m.status = 'active'
INNER JOIN companies c ON c.id = m.company_id
WHERE u.is_active = true
ORDER BY u.id,
CASE WHEN c.id = $1 THEN 0 ELSE 1 END,
CASE WHEN COALESCE(c.legacy_company_id, '') = $2 THEN 0
WHEN c.name IN ('A1 Slovenija', 'Local Demo Co') THEN 0
ELSE 1 END,
c.name ASC`, activeCompanyID, billing.A1LegacyCompanyID)
if err != nil {
LogAndError(w, http.StatusInternalServerError, "list failed", err)
return
}
defer rows.Close()
out := make([]switchableUserRow, 0)
for rows.Next() {
var u switchableUserRow
var legacyCompanyID string
if err := rows.Scan(
&u.ID, &u.Email, &u.Name, &u.LegacyUserID, &u.MembershipRole,
&u.CompanyID, &u.CompanyName, &legacyCompanyID,
); err != nil {
Error(w, http.StatusInternalServerError, "scan failed")
return
}
enrichSwitchableUser(&u, legacyCompanyID)
out = append(out, u)
}
if err := rows.Err(); err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
sort.SliceStable(out, func(i, j int) bool {
ai := out[i].CompanyID == activeCompanyID
aj := out[j].CompanyID == activeCompanyID
if ai != aj {
return ai
}
if out[i].CompanyLabel != out[j].CompanyLabel {
return out[i].CompanyLabel < out[j].CompanyLabel
}
// Demo admin + primary A1 first within a company group.
rank := func(u switchableUserRow) int {
if u.IsDemoAdmin {
return 0
}
if u.IsPrimaryA1 {
return 1
}
return 2
}
ri, rj := rank(out[i]), rank(out[j])
if ri != rj {
return ri < rj
}
return strings.ToLower(out[i].Label) < strings.ToLower(out[j].Label)
})
payload := map[string]any{"users": out}
if impStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionImpersonatorIDKey)); impStr != "" {
payload["impersonating"] = true
if impID, err := uuid.Parse(impStr); err == nil {
if impUser, err := s.Auth.GetUser(r.Context(), impID); err == nil {
payload["impersonator"] = map[string]any{
"id": impUser.ID,
"email": impUser.Email,
"name": impUser.Name,
}
}
}
}
JSON(w, http.StatusOK, payload)
}
@@ -0,0 +1,32 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestRouterProductionOmitsImpersonationRoutes(t *testing.T) {
t.Parallel()
s := testAPIServer()
s.Config.AppEnv = "production"
h := s.Router()
for _, path := range []string{
"/api/admin/users/00000000-0000-0000-0000-000000000001/impersonate",
"/api/admin/dev/stop-impersonate",
"/api/admin/dev/switchable-users",
} {
rec := httptest.NewRecorder()
method := http.MethodPost
if path == "/api/admin/dev/switchable-users" {
method = http.MethodGet
}
h.ServeHTTP(rec, httptest.NewRequest(method, path, nil))
// Unauthenticated session yields 401; production must not expose the route as 200/403 from the handler.
// Mounted routes behind RequireSession return 401; unmounted chi paths under /api/admin still hit RequireSession then 404 for unknown — either way not a successful switch.
if rec.Code == http.StatusOK {
t.Fatalf("%s returned 200 in production", path)
}
}
}
@@ -0,0 +1,97 @@
package httpapi
import (
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
)
func TestCompanyDisplayLabel(t *testing.T) {
t.Parallel()
got := companyDisplayLabel("A1 Slovenija", billing.A1LegacyCompanyID)
if got != "A1 Slovenija" {
t.Fatalf("got %q", got)
}
got = companyDisplayLabel("Local Demo Co", billing.A1LegacyCompanyID)
if got != "A1 Slovenija" {
t.Fatalf("legacy rename alias got %q", got)
}
got = companyDisplayLabel("Other Co", "")
if got != "Other Co" {
t.Fatalf("got %q", got)
}
}
func TestEnrichSwitchableUserLabels(t *testing.T) {
t.Parallel()
demo := switchableUserRow{Email: "demo@descrybe.local", CompanyName: "A1 Slovenija"}
enrichSwitchableUser(&demo, billing.A1LegacyCompanyID)
if !demo.IsDemoAdmin || demo.Label != "Demo admin" {
t.Fatalf("demo: %+v", demo)
}
if demo.CompanyLabel != "A1 Slovenija" {
t.Fatalf("company label: %q", demo.CompanyLabel)
}
legacyID := "user_30AqqJ8uepxvPUzDSqy81U5w6Ll"
name := "A1 user"
primary := switchableUserRow{
Email: "a1-primary@descrybe.local",
Name: &name,
LegacyUserID: &legacyID,
CompanyName: "A1 Slovenija",
}
enrichSwitchableUser(&primary, billing.A1LegacyCompanyID)
if !primary.IsPrimaryA1 {
t.Fatalf("expected primary A1")
}
if primary.Label != "A1 · …81U5w6Ll" {
t.Fatalf("primary label: %q", primary.Label)
}
if primary.Subtitle != "A1 Slovenija · user_30AqqJ8uepxvPUzDSqy81U5w6Ll" {
t.Fatalf("primary subtitle: %q", primary.Subtitle)
}
// Fallback path: legacy synthetic email still maps via Clerk id.
legacyEmailPrimary := switchableUserRow{
Email: "user_30aqqj8uepxvpuzdsqy81u5w6ll@legacy.local",
LegacyUserID: &legacyID,
CompanyName: "A1 Slovenija",
}
enrichSwitchableUser(&legacyEmailPrimary, billing.A1LegacyCompanyID)
if !legacyEmailPrimary.IsPrimaryA1 {
t.Fatalf("expected primary via legacy clerk id")
}
if legacyEmailPrimary.Label != "A1 · …81U5w6Ll" {
t.Fatalf("legacy primary label: %q", legacyEmailPrimary.Label)
}
otherID := "user_2tJxuYMnKOx8u9CrMvNA9sU2QMs"
other := switchableUserRow{
Email: "user_2tjxuymnkox8u9crmvna9su2qms@legacy.local",
LegacyUserID: &otherID,
CompanyName: "A1 Slovenija",
}
enrichSwitchableUser(&other, billing.A1LegacyCompanyID)
if other.IsPrimaryA1 || other.IsDemoAdmin {
t.Fatalf("other should be plain A1 member: %+v", other)
}
if other.Label != "A1 · …A9sU2QMs" {
t.Fatalf("other label: %q", other.Label)
}
if other.Subtitle != "A1 Slovenija · user_2tJxuYMnKOx8u9CrMvNA9sU2QMs" {
t.Fatalf("other subtitle: %q", other.Subtitle)
}
// Non-A1 company with a clerk id must not get A1 labels.
nonA1 := switchableUserRow{
Email: "user_2tjxuymnkox8u9crmvna9su2qms@legacy.local",
LegacyUserID: &otherID,
CompanyName: "Other Co",
}
enrichSwitchableUser(&nonA1, "")
if nonA1.IsPrimaryA1 || nonA1.Label == "A1 · …A9sU2QMs" {
t.Fatalf("non-A1 company should not use A1 label: %+v", nonA1)
}
}
@@ -0,0 +1,817 @@
package httpapi
import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/jobs"
"github.com/descrybe/descrybe-v2/apps/api/internal/metrics"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid"
)
const (
adminDiagnosticsTimeout = 3 * time.Second
adminDiagnosticsDefaultFails = 25
adminDiagnosticsMaxFails = 50
adminDiagnosticsStuckAfter = 2 * time.Hour
adminDiagnosticsAIFailDefault = 15
adminDiagnosticsAIFailMax = 30
// Schema head expected by cutover-deploy-check (goose 039042).
adminDiagnosticsGooseExpectedMin = int64(42)
)
// Required goose versions for cutover readiness (match scripts/cutover-deploy-check.mjs).
var adminDiagnosticsGooseRequired = []struct {
ID int64
Name string
}{
{39, "039_worker_heartbeats"},
{40, "040_job_hotpath_indexes"},
{41, "041_password_reset_tokens"},
{42, "042_user_session_version"},
}
// handleAdminDiagnostics returns operational health for platform admins.
// GET /api/admin/diagnostics?failures_limit=25&status=failed
// Never exposes secrets, DSNs, API keys, or passwords.
func (s *Server) handleAdminDiagnostics(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), adminDiagnosticsTimeout)
defer cancel()
failLimit := adminDiagnosticsDefaultFails
if raw := strings.TrimSpace(r.URL.Query().Get("failures_limit")); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
failLimit = n
}
}
if failLimit > adminDiagnosticsMaxFails {
failLimit = adminDiagnosticsMaxFails
}
statusFilter := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("status")))
switch statusFilter {
case "", "all", "failed", "running", "pending", "completed", "cancelled":
default:
Error(w, http.StatusBadRequest, "invalid status filter")
return
}
if statusFilter == "all" {
statusFilter = ""
}
checks := make([]map[string]any, 0, 7)
overall := "ok"
stripeCfg := s.resolveStripeDiagCfg(ctx)
dbCheck, dbOK := s.diagDatabase(ctx)
checks = append(checks, dbCheck)
if !dbOK {
overall = "fail"
}
queueCheck, queueSummary, queueOK := s.diagQueue(ctx)
checks = append(checks, queueCheck)
if !queueOK && overall != "fail" {
overall = "degraded"
}
cacheCheck := s.diagCache()
checks = append(checks, cacheCheck)
storageCheck, storageOK := s.diagStorage()
checks = append(checks, storageCheck)
if !storageOK && overall != "fail" {
overall = "degraded"
}
mailCheck := s.diagMail()
checks = append(checks, mailCheck)
stripeCheck, stripeOK := diagStripeReadiness(s.Config.IsProduction(), stripeCfg)
checks = append(checks, stripeCheck)
if !stripeOK && overall == "ok" {
overall = "degraded"
}
if stripeCheck["status"] == "fail" {
overall = "fail"
}
cutover := s.diagCutoverReadiness(ctx)
cutoverCheck := map[string]any{
"name": "cutover",
"status": cutover["status"],
}
if detail, ok := cutover["detail"].(string); ok && detail != "" {
cutoverCheck["detail"] = detail
}
checks = append(checks, cutoverCheck)
if st, _ := cutover["status"].(string); st == "warn" && overall == "ok" {
overall = "degraded"
}
if st, _ := cutover["status"].(string); st == "fail" {
overall = "fail"
}
failures, failErr := s.diagRecentJobFailures(ctx, failLimit, statusFilter)
if failErr != nil && overall == "ok" {
overall = "degraded"
}
if statusFilter == "" || statusFilter == "failed" {
if n, ok := queueSummary["failed"].(int64); ok && n > 0 && overall == "ok" {
overall = "degraded"
}
if n, ok := queueSummary["stuck_running"].(int64); ok && n > 0 && overall == "ok" {
overall = "degraded"
}
}
aiFails, _ := s.diagRecentAIFailures(ctx, adminDiagnosticsAIFailDefault)
migrationInventory := s.diagMigrationInventory(ctx)
JSON(w, http.StatusOK, map[string]any{
"status": overall,
"generated_at": time.Now().UTC().Format(time.RFC3339),
"checks": checks,
"queue": queueSummary,
"cutover": cutover,
"migration_inventory": migrationInventory,
"config": s.diagConfigSanity(stripeCfg),
"runtime_metrics": metrics.Snapshot(),
"recent_failures": failures,
"recent_ai_failures": aiFails,
"filters": map[string]any{
"status": statusFilter,
"failures_limit": failLimit,
},
"links": map[string]string{
"stuck_products": "/admin/stuck-products",
"orphan_processed": "/admin/orphan-processed",
"tasks_cleanup": "/admin/tasks-cleanup",
"logs": "/admin/logs",
"bootstrap": "/admin/bootstrap",
"analytics": "/admin/analytics",
"metrics": "/metrics",
"readiness": "/api/admin/readiness",
},
"notes": []string{
"Diagnostics is for troubleshooting, not marketing analytics.",
"Secrets, passwords, and API keys are never included.",
"/admin/logs redirects here; stuck cleanup lives under Stuck products.",
"Orphan processed: /admin/orphan-processed (dry-run report → confirm delete). API: GET/POST /api/admin/jobs/orphan-processed(-cleanup); POST needs confirm=true.",
"Prometheus scrape: GET /metrics (HTTP RED). In production: loopback only unless METRICS_PUBLIC=1. Worker sync series need METRICS_ADDR on the worker process.",
"Cutover block: goose version hints + worker age + companies_without_plan + companies_without_api_keys (reissue inventory; presence/counts only; no live Stripe/SMTP; no fake key migration).",
"migration_inventory: read-only COUNT of metadata-only files + jobs/history tags — not an import path; blob bytes and default job history stay unmigrated unless ops ran optional domain jobs.",
},
})
}
func (s *Server) diagDatabase(ctx context.Context) (check map[string]any, ok bool) {
start := time.Now()
var pinger dbPinger
if s.Pool != nil {
pinger = s.Pool
}
ready, status, errMsg := databaseReady(ctx, pinger)
check = map[string]any{
"name": "database",
"status": status,
"latency_ms": time.Since(start).Milliseconds(),
}
if !ready {
check["status"] = "fail"
if errMsg != "" {
check["detail"] = errMsg
}
return check, false
}
check["status"] = "ok"
check["detail"] = "ping ok"
return check, true
}
func (s *Server) diagQueue(ctx context.Context) (check map[string]any, summary map[string]any, ok bool) {
summary = map[string]any{
"by_status": map[string]int64{},
"stuck_running": int64(0),
"driver": "postgres_processing_jobs",
}
check = map[string]any{
"name": "queue",
"status": "ok",
"detail": "processing_jobs poller (SKIP LOCKED)",
}
if s.Pool == nil {
check["status"] = "fail"
check["detail"] = "database pool unavailable"
return check, summary, false
}
start := time.Now()
rows, err := s.Pool.Query(ctx, `
SELECT status, COUNT(*)::bigint
FROM processing_jobs
GROUP BY status`)
if err != nil {
check["status"] = "fail"
check["detail"] = "queue status query failed"
check["latency_ms"] = time.Since(start).Milliseconds()
return check, summary, false
}
defer rows.Close()
byStatus := map[string]int64{}
var total int64
for rows.Next() {
var st string
var n int64
if err := rows.Scan(&st, &n); err != nil {
check["status"] = "fail"
check["detail"] = "queue status scan failed"
check["latency_ms"] = time.Since(start).Milliseconds()
return check, summary, false
}
byStatus[st] = n
total += n
summary[st] = n
}
if err := rows.Err(); err != nil {
check["status"] = "fail"
check["detail"] = "queue status rows failed"
check["latency_ms"] = time.Since(start).Milliseconds()
return check, summary, false
}
summary["by_status"] = byStatus
summary["total"] = total
var stuck int64
_ = s.Pool.QueryRow(ctx, `
SELECT COUNT(*)::bigint FROM processing_jobs
WHERE status = 'running'
AND updated_at < now() - make_interval(secs => $1)`,
adminDiagnosticsStuckAfter.Seconds(),
).Scan(&stuck)
summary["stuck_running"] = stuck
check["latency_ms"] = time.Since(start).Milliseconds()
if stuck > 0 {
check["status"] = "warn"
check["detail"] = "stuck running jobs detected"
return check, summary, false
}
return check, summary, true
}
func (s *Server) diagCache() map[string]any {
// No Redis/memcached in this stack — support KB uses process-local cache only.
return map[string]any{
"name": "cache",
"status": "ok",
"detail": "in-process only (no external cache)",
}
}
func (s *Server) diagStorage() (check map[string]any, ok bool) {
check = map[string]any{
"name": "storage",
"status": "ok",
}
dir := strings.TrimSpace(s.Config.UploadDir)
if dir == "" {
check["status"] = "warn"
check["detail"] = "upload dir not configured"
return check, false
}
abs, err := filepath.Abs(dir)
if err != nil {
check["status"] = "fail"
check["detail"] = "upload dir path invalid"
return check, false
}
info, err := os.Stat(abs)
if err != nil {
check["status"] = "fail"
if os.IsNotExist(err) {
check["detail"] = "upload dir missing"
} else {
check["detail"] = "upload dir unavailable"
}
return check, false
}
if !info.IsDir() {
check["status"] = "fail"
check["detail"] = "upload path is not a directory"
return check, false
}
probe := filepath.Join(abs, ".diag_write_probe")
if err := os.WriteFile(probe, []byte("ok"), 0o600); err != nil {
check["status"] = "fail"
check["detail"] = "upload dir not writable"
return check, false
}
_ = os.Remove(probe)
// Never return absolute path (may leak host layout); only configured relative name.
check["detail"] = "upload dir writable"
check["configured"] = true
return check, true
}
func (s *Server) diagMail() map[string]any {
enabled := s.Config.SMTPEnabled
if s.Mail != nil {
enabled = s.Mail.Enabled()
}
dryRun := s.Config.EmailDryRun
hostSet := strings.TrimSpace(s.Config.SMTPHost) != ""
status := "ok"
detail := "smtp disabled (noop)"
switch {
case !enabled:
detail = "smtp disabled (noop)"
case dryRun && hostSet:
detail = "smtp enabled; dry-run; host set"
case dryRun && !hostSet:
detail = "smtp enabled; dry-run; host not set"
status = "warn"
case hostSet:
detail = "smtp enabled; host set"
default:
detail = "smtp enabled; host not set"
status = "warn"
}
// Presence flags only — never host hostname or credentials.
return map[string]any{
"name": "mail",
"status": status,
"detail": detail,
"enabled": enabled,
"dry_run": dryRun,
"host_set": hostSet,
}
}
// diagCutoverReadiness reports deploy/cutover presence signals for platform admins.
// Goose version hints + worker heartbeat age + cheap companies_without_plan /
// companies_without_api_keys counts (reissue inventory; no fake key migration).
// Never runs live Stripe charges or SMTP sends; never returns secrets/DSNs.
func (s *Server) diagCutoverReadiness(ctx context.Context) map[string]any {
goose := s.diagGooseVersionHints(ctx)
worker := s.diagWorkerAge(ctx)
out := map[string]any{
"status": "ok",
"detail": "cutover presence ok",
"goose": goose,
"worker": worker,
}
if n, ok := s.diagCompaniesWithoutPlan(ctx); ok {
out["companies_without_plan"] = n
}
if n, ok := s.diagCompaniesWithoutAPIKeys(ctx); ok {
out["companies_without_api_keys"] = n
}
status := "ok"
detail := "cutover presence ok"
gooseStatus, _ := goose["status"].(string)
workerStatus, _ := worker["status"].(string)
switch {
case gooseStatus == "fail" || workerStatus == "fail":
status = "fail"
detail = "cutover probe failed"
case gooseStatus == "warn" || gooseStatus == "skip":
status = "warn"
if d, ok := goose["detail"].(string); ok && d != "" {
detail = d
} else {
detail = "goose version hints incomplete"
}
case workerStatus == "missing" || workerStatus == "stale" || workerStatus == "unavailable":
status = "warn"
if d, ok := worker["detail"].(string); ok && d != "" {
detail = d
} else {
detail = "worker heartbeat not fresh"
}
}
out["status"] = status
out["detail"] = detail
return out
}
func (s *Server) diagGooseVersionHints(ctx context.Context) map[string]any {
required := make(map[string]bool, len(adminDiagnosticsGooseRequired))
ids := make([]int64, 0, len(adminDiagnosticsGooseRequired))
idToName := make(map[int64]string, len(adminDiagnosticsGooseRequired))
for _, m := range adminDiagnosticsGooseRequired {
required[m.Name] = false
ids = append(ids, m.ID)
idToName[m.ID] = m.Name
}
out := map[string]any{
"status": "skip",
"detail": "database unavailable",
"required": required,
"expected_min": adminDiagnosticsGooseExpectedMin,
}
if s.Pool == nil {
return out
}
var versionMax int64
if err := s.Pool.QueryRow(ctx, `
SELECT COALESCE(MAX(version_id), 0)::bigint
FROM goose_db_version
WHERE is_applied = true`).Scan(&versionMax); err != nil {
out["status"] = "warn"
out["detail"] = "goose version query unavailable"
return out
}
out["version_max"] = versionMax
rows, err := s.Pool.Query(ctx, `
SELECT version_id::bigint
FROM goose_db_version
WHERE is_applied = true AND version_id = ANY($1)`, ids)
if err != nil {
out["status"] = "warn"
out["detail"] = "goose required migration query unavailable"
return out
}
defer rows.Close()
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
out["status"] = "warn"
out["detail"] = "goose required migration scan failed"
return out
}
if name, ok := idToName[id]; ok {
required[name] = true
}
}
if err := rows.Err(); err != nil {
out["status"] = "warn"
out["detail"] = "goose required migration rows failed"
return out
}
out["required"] = required
allApplied := true
for _, m := range adminDiagnosticsGooseRequired {
if !required[m.Name] {
allApplied = false
break
}
}
if !allApplied {
out["status"] = "warn"
out["detail"] = "required cutover migrations missing"
return out
}
if versionMax < adminDiagnosticsGooseExpectedMin {
out["status"] = "warn"
out["detail"] = "schema behind expected head"
return out
}
out["status"] = "ok"
out["detail"] = "required migrations applied"
return out
}
func (s *Server) diagWorkerAge(ctx context.Context) map[string]any {
staleAfterS := int64(jobs.DefaultHeartbeatStaleAfter / time.Second)
out := map[string]any{
"status": "unavailable",
"detail": "worker probe unavailable",
"stale_after_s": staleAfterS,
}
var prober jobs.HeartbeatQuerier
if s.Pool != nil {
prober = s.Pool
}
probe := jobs.ProbeWorkerReadiness(ctx, prober, jobs.DefaultHeartbeatStaleAfter)
out["status"] = probe.WorkerCheck
if probe.LastSeenAgeS >= 0 {
out["last_seen_age_s"] = probe.LastSeenAgeS
}
if probe.Reason != "" {
out["reason"] = probe.Reason
}
switch probe.WorkerCheck {
case "ok":
out["detail"] = "worker heartbeat fresh"
case "missing":
out["detail"] = "worker heartbeat missing"
case "stale":
out["detail"] = "worker heartbeat stale"
case "fail":
out["detail"] = "worker heartbeat query failed"
default:
out["detail"] = "worker probe unavailable"
}
return out
}
// diagCompaniesWithoutPlan is the cheap cutover hypercare count (same shape as /api/admin/readiness).
func (s *Server) diagCompaniesWithoutPlan(ctx context.Context) (count int64, ok bool) {
if s.Pool == nil {
return 0, false
}
err := s.Pool.QueryRow(ctx, `
SELECT COUNT(*)::bigint FROM companies c
WHERE c.id <> $1
AND NOT EXISTS (
SELECT 1 FROM company_plans cp
WHERE cp.company_id = c.id AND cp.is_active = true
)`, platformsettings.SystemCompanyID).Scan(&count)
if err != nil {
return 0, false
}
return count, true
}
// diagCompaniesWithoutAPIKeys counts tenants with no non-revoked api_keys.
// Legacy secrets were not migrated — inventory for reissue only (no key invent/import).
func (s *Server) diagCompaniesWithoutAPIKeys(ctx context.Context) (count int64, ok bool) {
if s.Pool == nil {
return 0, false
}
err := s.Pool.QueryRow(ctx, `
SELECT COUNT(*)::bigint FROM companies c
WHERE c.id <> $1
AND NOT EXISTS (
SELECT 1 FROM api_keys k
WHERE k.company_id = c.id AND k.revoked_at IS NULL
)`, platformsettings.SystemCompanyID).Scan(&count)
if err != nil {
return 0, false
}
return count, true
}
// diagMigrationInventory returns cheap read-only COUNTs for accepted ETL gaps
// (metadata-only file blobs, optional jobs-domain backfill, tasks history).
// Never imports or invents data; never exposes paths/secrets.
func (s *Server) diagMigrationInventory(ctx context.Context) map[string]any {
notes := []string{
"File blob bytes were never ETL'd — files_metadata_only tags metadata_only_resync_paths or _legacy_file_id.",
"Cutover default skips domain jobs; processing_jobs_migrated>0 means optional jobs backfill ran (ai_provider_mode=migrated).",
"tasks_total is present history only — no migrated tag on tasks; empty Processing UI after cutover is expected unless jobs ran.",
"Read-only inventory — no fake blob/job import from this endpoint.",
}
out := map[string]any{
"status": "skip",
"detail": "database unavailable",
"files_total": int64(0),
"files_metadata_only": int64(0),
"processing_jobs_total": int64(0),
"processing_jobs_migrated": int64(0),
"tasks_total": int64(0),
"jobs_domain_ran": false,
"notes": notes,
}
if s.Pool == nil {
return out
}
var filesTotal, filesMeta, jobsTotal, jobsMigrated, tasksTotal int64
err := s.Pool.QueryRow(ctx, `
SELECT
(SELECT COUNT(*)::bigint FROM files),
(SELECT COUNT(*)::bigint FROM files
WHERE COALESCE(metadata->>'_blob_strategy', '') = 'metadata_only_resync_paths'
OR metadata ? '_legacy_file_id'),
(SELECT COUNT(*)::bigint FROM processing_jobs),
(SELECT COUNT(*)::bigint FROM processing_jobs
WHERE COALESCE(ai_provider_mode, '') = 'migrated'),
(SELECT COUNT(*)::bigint FROM tasks)`).Scan(
&filesTotal, &filesMeta, &jobsTotal, &jobsMigrated, &tasksTotal,
)
if err != nil {
out["status"] = "warn"
out["detail"] = "migration inventory query failed"
return out
}
out["files_total"] = filesTotal
out["files_metadata_only"] = filesMeta
out["processing_jobs_total"] = jobsTotal
out["processing_jobs_migrated"] = jobsMigrated
out["tasks_total"] = tasksTotal
out["jobs_domain_ran"] = jobsMigrated > 0
out["status"] = "ok"
out["detail"] = "read-only ETL gap inventory"
return out
}
// resolveStripeDiagCfg merges env bootstrap with platform_settings when available.
// Presence flags only — never returns secret values to callers that stringify cfg.
func (s *Server) resolveStripeDiagCfg(ctx context.Context) billing.StripeConfig {
if s.Stripe != nil {
base := s.Stripe.Cfg
if s.Stripe.ResolveCfg != nil {
if cfg, err := s.Stripe.ResolveCfg(ctx, base); err == nil {
return cfg
}
}
return base
}
return billing.StripeConfig{
SecretKey: s.Config.StripeSecretKey,
WebhookSecret: s.Config.StripeWebhookSecret,
ForceMock: s.Config.StripeMock,
}
}
// diagStripeReadiness reports Stripe ops readiness without leaking secret values.
// Production: mock must be off; missing secret/webhook keys degrade (fail-closed at use).
func diagStripeReadiness(prod bool, cfg billing.StripeConfig) (check map[string]any, ok bool) {
secretSet := strings.TrimSpace(cfg.SecretKey) != ""
webhookSet := strings.TrimSpace(cfg.WebhookSecret) != ""
mock := cfg.ForceMock
mockRejectedInProd := !prod || !mock
check = map[string]any{
"name": "stripe",
"status": "ok",
"secret_key_set": secretSet,
"webhook_secret_set": webhookSet,
"mock": mock,
"mock_rejected_in_prod": mockRejectedInProd,
}
if prod && mock {
check["status"] = "fail"
check["detail"] = "STRIPE_MOCK must be false in production"
return check, false
}
if prod && (!secretSet || !webhookSet) {
check["status"] = "warn"
parts := make([]string, 0, 2)
if !secretSet {
parts = append(parts, "secret key")
}
if !webhookSet {
parts = append(parts, "webhook secret")
}
check["detail"] = "missing " + strings.Join(parts, " and ") + " (checkout/webhooks fail closed)"
return check, false
}
if mock {
check["detail"] = "mock mode enabled"
return check, true
}
if !secretSet {
check["status"] = "warn"
check["detail"] = "secret key not set (mock purchases require STRIPE_MOCK)"
return check, false
}
if !webhookSet {
check["status"] = "warn"
check["detail"] = "webhook secret not set"
return check, false
}
check["detail"] = "keys present"
return check, true
}
func (s *Server) diagConfigSanity(stripe billing.StripeConfig) map[string]any {
smtpEnabled := s.Config.SMTPEnabled
if s.Mail != nil {
smtpEnabled = s.Mail.Enabled()
}
secretSet := strings.TrimSpace(stripe.SecretKey) != ""
webhookSet := strings.TrimSpace(stripe.WebhookSecret) != ""
return map[string]any{
"app_env": s.Config.AppEnv,
"maintenance_mode": s.Config.MaintenanceMode,
"read_only_mode": s.Config.ReadOnlyMode,
"session_secure": s.Config.SessionSecure,
"smtp_enabled": smtpEnabled,
"email_dry_run": s.Config.EmailDryRun,
"smtp_host_set": strings.TrimSpace(s.Config.SMTPHost) != "",
"stripe_mock": stripe.ForceMock,
"eprel_enabled": s.Config.EPRELEnabled,
"processing_rpm": s.Config.ProcessingRPM,
"processing_batch_size": s.Config.ProcessingBatchSize,
"processing_max_retries": s.Config.ProcessingMaxRetries,
"upload_dir_configured": strings.TrimSpace(s.Config.UploadDir) != "",
"trusted_proxies_configured": len(s.Config.TrustedProxies) > 0,
"web_origin_set": strings.TrimSpace(s.Config.WebOrigin) != "",
"public_api_url_set": strings.TrimSpace(s.Config.PublicAPIURL) != "",
// Presence flags only — never the secret values.
"token_signing_secret_set": strings.TrimSpace(s.Config.TokenSigningSecret) != "",
"openai_key_set": strings.TrimSpace(s.Config.OpenAIAPIKey) != "",
"pinecone_key_set": strings.TrimSpace(s.Config.PineconeAPIKey) != "",
"stripe_secret_set": secretSet,
"stripe_webhook_secret_set": webhookSet,
"stripe_mock_rejected_in_prod": !s.Config.IsProduction() || !stripe.ForceMock,
"credentials_encryption_key_set": strings.TrimSpace(s.Config.CredentialsEncryptionKey) != "",
}
}
func (s *Server) diagRecentJobFailures(ctx context.Context, limit int, statusFilter string) ([]map[string]any, error) {
out := make([]map[string]any, 0)
if s.Pool == nil {
return out, errors.New("database unavailable")
}
status := statusFilter
if status == "" {
status = "failed"
}
rows, err := s.Pool.Query(ctx, `
SELECT id, company_id, status, total_products, processed_products, error, created_at, updated_at
FROM processing_jobs
WHERE status = $1
ORDER BY updated_at DESC
LIMIT $2`, status, limit)
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var (
id, companyID uuid.UUID
st string
total, processed int
errMsg *string
createdAt, updatedAt time.Time
)
if err := rows.Scan(&id, &companyID, &st, &total, &processed, &errMsg, &createdAt, &updatedAt); err != nil {
return out, err
}
safeErr := ""
if errMsg != nil && *errMsg != "" {
safeErr = processing.TruncateError(errors.New(*errMsg))
}
out = append(out, map[string]any{
"id": id,
"company_id": companyID,
"status": st,
"total_products": total,
"processed_products": processed,
"error": safeErr,
"created_at": createdAt.UTC().Format(time.RFC3339),
"updated_at": updatedAt.UTC().Format(time.RFC3339),
})
}
return out, rows.Err()
}
func (s *Server) diagRecentAIFailures(ctx context.Context, limit int) ([]map[string]any, error) {
out := make([]map[string]any, 0)
if s.Pool == nil {
return out, nil
}
if limit <= 0 {
limit = adminDiagnosticsAIFailDefault
}
if limit > adminDiagnosticsAIFailMax {
limit = adminDiagnosticsAIFailMax
}
rows, err := s.Pool.Query(ctx, `
SELECT id, ticket_id, company_id, kind, created_at
FROM support_ticket_activity
WHERE kind = 'ai_failed'
ORDER BY created_at DESC
LIMIT $1`, limit)
if err != nil {
// Table may be absent on older DBs — soft-skip.
return out, nil
}
defer rows.Close()
for rows.Next() {
var (
id, ticketID, companyID uuid.UUID
kind string
createdAt time.Time
)
if err := rows.Scan(&id, &ticketID, &companyID, &kind, &createdAt); err != nil {
return out, nil
}
out = append(out, map[string]any{
"id": id,
"ticket_id": ticketID,
"company_id": companyID,
"kind": kind,
"created_at": createdAt.UTC().Format(time.RFC3339),
})
}
return out, nil
}
@@ -0,0 +1,432 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid"
)
func TestHandleAdminDiagnosticsNilPool(t *testing.T) {
t.Parallel()
dir := t.TempDir()
s := &Server{Config: config.Config{UploadDir: dir, AppEnv: "test"}}
req := httptest.NewRequest(http.MethodGet, "/api/admin/diagnostics", nil)
rec := httptest.NewRecorder()
s.handleAdminDiagnostics(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d want 200 body=%s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("json: %v", err)
}
if body["status"] != "fail" {
t.Fatalf("overall status=%v want fail", body["status"])
}
if _, ok := body["runtime_metrics"].(map[string]any); !ok {
t.Fatalf("expected runtime_metrics object, got %#v", body["runtime_metrics"])
}
cutover, ok := body["cutover"].(map[string]any)
if !ok {
t.Fatalf("expected cutover object, got %#v", body["cutover"])
}
if _, ok := cutover["goose"].(map[string]any); !ok {
t.Fatalf("expected cutover.goose object, got %#v", cutover["goose"])
}
if _, ok := cutover["worker"].(map[string]any); !ok {
t.Fatalf("expected cutover.worker object, got %#v", cutover["worker"])
}
if _, hasPlans := cutover["companies_without_plan"]; hasPlans {
t.Fatal("nil pool must omit companies_without_plan (query skipped)")
}
links, _ := body["links"].(map[string]any)
if links["metrics"] != "/metrics" {
t.Fatalf("links.metrics=%v want /metrics", links["metrics"])
}
if links["readiness"] != "/api/admin/readiness" {
t.Fatalf("links.readiness=%v want /api/admin/readiness", links["readiness"])
}
cfg, _ := body["config"].(map[string]any)
for _, secretKey := range []string{
"database_url", "token_signing_secret", "openai_api_key", "smtp_password",
"stripe_secret_key", "pinecone_api_key", "password",
} {
if _, ok := cfg[secretKey]; ok {
t.Fatalf("config must not expose %q", secretKey)
}
}
raw := strings.ToLower(rec.Body.String())
for _, leak := range []string{"sk_live", "password=", "postgres://", "bearer "} {
if strings.Contains(raw, leak) {
t.Fatalf("response leaked secret-like substring %q", leak)
}
}
}
func TestHandleAdminDiagnosticsInvalidStatus(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{UploadDir: t.TempDir()}}
req := httptest.NewRequest(http.MethodGet, "/api/admin/diagnostics?status=bogus", nil)
rec := httptest.NewRecorder()
s.handleAdminDiagnostics(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d want 400 body=%s", rec.Code, rec.Body.String())
}
}
// TestDiagConfigSanityNeverEmitsSecretValues seeds Config with realistic secrets and
// asserts the diagnostics payload only exposes presence flags — never values/DSNs.
func TestDiagConfigSanityNeverEmitsSecretValues(t *testing.T) {
t.Parallel()
s := &Server{
Config: config.Config{
AppEnv: "production",
UploadDir: t.TempDir(),
DatabaseURL: "postgres://descrybe:s3cret@localhost:5433/descrybe",
TokenSigningSecret: "super-secret-token-signing-key",
OpenAIAPIKey: "sk-abcdefghijklmnopqrstuvwxyz0123456789",
PineconeAPIKey: "pcsk_live_example_key_value",
StripeSecretKey: "sk_live_51ExampleSecretValue",
StripeWebhookSecret: "whsec_example_webhook_secret",
SMTPPassword: "smtp-password-value",
SMTPHost: "smtp.secret-host.example",
SMTPEnabled: true,
EmailDryRun: true,
CredentialsEncryptionKey: "creds-encryption-key-32bytes!!",
ResendAPIKey: "re_example_resend_key",
EPRELAPIKey: "eprel-secret-key",
WebOrigin: "https://app.example.com",
PublicAPIURL: "https://api.example.com",
},
}
stripeCfg := billing.StripeConfig{
SecretKey: s.Config.StripeSecretKey,
WebhookSecret: s.Config.StripeWebhookSecret,
ForceMock: s.Config.StripeMock,
}
cfg := s.diagConfigSanity(stripeCfg)
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
body := strings.ToLower(string(raw))
for _, leak := range []string{
"postgres://", "s3cret", "super-secret-token",
"sk-abcdefghijklmnopqrstuvwxyz", "sk_live_51", "whsec_",
"smtp-password", "creds-encryption", "re_example", "eprel-secret",
"database_url", "openai_api_key", "smtp_password", "smtp.secret-host",
} {
if strings.Contains(body, strings.ToLower(leak)) {
t.Fatalf("config sanity leaked %q in %s", leak, body)
}
}
if cfg["openai_key_set"] != true || cfg["stripe_secret_set"] != true || cfg["stripe_webhook_secret_set"] != true {
t.Fatalf("expected presence flags true, got openai=%v stripe=%v webhook=%v",
cfg["openai_key_set"], cfg["stripe_secret_set"], cfg["stripe_webhook_secret_set"])
}
if cfg["smtp_enabled"] != true || cfg["email_dry_run"] != true || cfg["smtp_host_set"] != true {
t.Fatalf("expected mail presence flags true, got enabled=%v dry_run=%v host_set=%v",
cfg["smtp_enabled"], cfg["email_dry_run"], cfg["smtp_host_set"])
}
if cfg["stripe_mock_rejected_in_prod"] != true {
t.Fatalf("expected stripe_mock_rejected_in_prod=true, got %v", cfg["stripe_mock_rejected_in_prod"])
}
if _, ok := cfg["database_url"]; ok {
t.Fatal("database_url must not appear in config sanity")
}
}
func TestDiagMailConfigStatusPresenceOnly(t *testing.T) {
t.Parallel()
t.Run("ready dry-run with host", func(t *testing.T) {
t.Parallel()
s := &Server{
Config: config.Config{
SMTPEnabled: true,
SMTPHost: "smtp.secret-host.example",
SMTPPassword: "smtp-password-value",
EmailDryRun: true,
},
}
check := s.diagMail()
if check["status"] != "ok" || check["enabled"] != true || check["dry_run"] != true || check["host_set"] != true {
t.Fatalf("check=%v", check)
}
raw, err := json.Marshal(check)
if err != nil {
t.Fatal(err)
}
body := strings.ToLower(string(raw))
for _, leak := range []string{"smtp.secret-host", "smtp-password", "smtp_password"} {
if strings.Contains(body, leak) {
t.Fatalf("mail check leaked %q in %s", leak, body)
}
}
})
t.Run("enabled without host warns", func(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{SMTPEnabled: true, EmailDryRun: false}}
check := s.diagMail()
if check["status"] != "warn" || check["host_set"] != false || check["dry_run"] != false {
t.Fatalf("check=%v", check)
}
})
t.Run("disabled noop", func(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{EmailDryRun: true}}
check := s.diagMail()
if check["status"] != "ok" || check["enabled"] != false || check["dry_run"] != true {
t.Fatalf("check=%v", check)
}
})
}
func TestDiagCutoverReadinessNilPool(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{AppEnv: "test"}}
cutover := s.diagCutoverReadiness(context.Background())
if cutover["status"] != "warn" {
t.Fatalf("status=%v want warn", cutover["status"])
}
goose, _ := cutover["goose"].(map[string]any)
if goose["status"] != "skip" {
t.Fatalf("goose.status=%v want skip", goose["status"])
}
if _, ok := goose["version_max"]; ok {
t.Fatal("nil pool must not invent goose version_max")
}
worker, _ := cutover["worker"].(map[string]any)
if worker["status"] != "unavailable" {
t.Fatalf("worker.status=%v want unavailable", worker["status"])
}
if _, ok := worker["last_seen_age_s"]; ok {
t.Fatal("nil pool must omit last_seen_age_s")
}
if _, ok := cutover["companies_without_plan"]; ok {
t.Fatal("nil pool must omit companies_without_plan")
}
raw, err := json.Marshal(cutover)
if err != nil {
t.Fatal(err)
}
body := strings.ToLower(string(raw))
for _, leak := range []string{"postgres://", "sk_live", "password=", "smtp_password", "bearer "} {
if strings.Contains(body, leak) {
t.Fatalf("cutover leaked %q in %s", leak, body)
}
}
}
func TestDiagMigrationInventoryNilPool(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{AppEnv: "test"}}
inv := s.diagMigrationInventory(context.Background())
if inv["status"] != "skip" {
t.Fatalf("status=%v want skip", inv["status"])
}
if inv["jobs_domain_ran"] != false {
t.Fatalf("jobs_domain_ran=%v want false", inv["jobs_domain_ran"])
}
for _, key := range []string{
"files_total", "files_metadata_only",
"processing_jobs_total", "processing_jobs_migrated", "tasks_total",
} {
n, ok := inv[key].(int64)
if !ok || n != 0 {
t.Fatalf("%s=%v want int64(0)", key, inv[key])
}
}
notes, ok := inv["notes"].([]string)
if !ok || len(notes) == 0 {
t.Fatalf("notes=%v want non-empty []string", inv["notes"])
}
raw, err := json.Marshal(inv)
if err != nil {
t.Fatal(err)
}
body := strings.ToLower(string(raw))
for _, leak := range []string{"postgres://", "sk_live", "password=", "/var/", "c:\\"} {
if strings.Contains(body, leak) {
t.Fatalf("migration inventory leaked %q in %s", leak, body)
}
}
}
func TestHandleAdminDiagnosticsIncludesMigrationInventory(t *testing.T) {
t.Parallel()
dir := t.TempDir()
s := &Server{Config: config.Config{UploadDir: dir, AppEnv: "test"}}
req := httptest.NewRequest(http.MethodGet, "/api/admin/diagnostics", nil)
rec := httptest.NewRecorder()
s.handleAdminDiagnostics(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d want 200 body=%s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("json: %v", err)
}
inv, ok := body["migration_inventory"].(map[string]any)
if !ok {
t.Fatalf("migration_inventory missing: %#v", body["migration_inventory"])
}
if inv["status"] != "skip" {
t.Fatalf("migration_inventory.status=%v want skip (nil pool)", inv["status"])
}
}
func TestDiagStripeReadiness(t *testing.T) {
t.Parallel()
t.Run("prod mock fails", func(t *testing.T) {
t.Parallel()
check, ok := diagStripeReadiness(true, billing.StripeConfig{
SecretKey: "sk_live_x", WebhookSecret: "whsec_x", ForceMock: true,
})
if ok || check["status"] != "fail" || check["mock_rejected_in_prod"] != false {
t.Fatalf("check=%v ok=%v", check, ok)
}
raw, _ := json.Marshal(check)
if strings.Contains(strings.ToLower(string(raw)), "sk_live") || strings.Contains(string(raw), "whsec_") {
t.Fatalf("leaked secret material: %s", raw)
}
})
t.Run("prod keys present", func(t *testing.T) {
t.Parallel()
check, ok := diagStripeReadiness(true, billing.StripeConfig{
SecretKey: "sk_live_x", WebhookSecret: "whsec_x",
})
if !ok || check["status"] != "ok" || check["secret_key_set"] != true || check["webhook_secret_set"] != true {
t.Fatalf("check=%v ok=%v", check, ok)
}
if check["mock_rejected_in_prod"] != true {
t.Fatalf("mock_rejected_in_prod=%v", check["mock_rejected_in_prod"])
}
})
t.Run("prod missing webhook warns", func(t *testing.T) {
t.Parallel()
check, ok := diagStripeReadiness(true, billing.StripeConfig{SecretKey: "sk_live_x"})
if ok || check["status"] != "warn" || check["webhook_secret_set"] != false {
t.Fatalf("check=%v ok=%v", check, ok)
}
})
t.Run("dev mock ok", func(t *testing.T) {
t.Parallel()
check, ok := diagStripeReadiness(false, billing.StripeConfig{ForceMock: true})
if !ok || check["status"] != "ok" || check["mock"] != true {
t.Fatalf("check=%v ok=%v", check, ok)
}
})
}
func TestDiagJobErrorUsesTruncateError(t *testing.T) {
t.Parallel()
// Contract lock: job error strings must go through TruncateError before JSON.
secretish := "provider failed authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz012345"
redacted := processing.TruncateError(errors.New(secretish))
if strings.Contains(strings.ToLower(redacted), "sk-abcdef") || strings.Contains(strings.ToLower(redacted), "bearer sk-") {
t.Fatalf("TruncateError did not redact: %q", redacted)
}
if redacted == "" {
t.Fatal("expected non-empty redacted message")
}
}
func TestHandleAdminDiagnosticsStorageWritable(t *testing.T) {
t.Parallel()
dir := t.TempDir()
s := &Server{Config: config.Config{UploadDir: dir}}
check, ok := s.diagStorage()
if !ok {
t.Fatalf("expected writable storage check=%v", check)
}
if check["status"] != "ok" {
t.Fatalf("status=%v", check["status"])
}
// Absolute path must not appear in detail.
if detail, _ := check["detail"].(string); filepath.IsAbs(detail) {
t.Fatalf("detail must not be absolute path: %q", detail)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.Name() == ".diag_write_probe" {
t.Fatal("probe file should be removed")
}
}
}
func TestRouterAdminDiagnosticsMounted(t *testing.T) {
t.Parallel()
sm := scs.New()
sm.Cookie.Name = "descrybe_session"
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{
Config: config.Config{
CSRFCookieName: "descrybe_csrf",
WebOrigin: "http://localhost:5173",
UploadDir: t.TempDir(),
},
Sessions: sm,
Auth: &auth.Service{},
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
return got == uid, nil
},
}
var token string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
token = c.Value
}
}
if token == "" {
t.Fatal("expected session cookie from seed request")
}
h := s.Router()
unauth := httptest.NewRecorder()
h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/admin/diagnostics", nil))
if unauth.Code != http.StatusUnauthorized {
t.Fatalf("unauth status=%d want 401 body=%s", unauth.Code, unauth.Body.String())
}
mounted := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/admin/diagnostics", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
h.ServeHTTP(mounted, req)
if mounted.Code == http.StatusNotFound {
t.Fatalf("diagnostics not mounted: status=404 body=%s", mounted.Body.String())
}
if mounted.Code != http.StatusOK {
t.Fatalf("mounted status=%d want 200 body=%s", mounted.Code, mounted.Body.String())
}
}
+310
View File
@@ -0,0 +1,310 @@
package httpapi
import (
"context"
"errors"
"log"
"net/http"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid"
)
const (
adminSetPasswordBulkLimit = 100
adminSetPasswordReqPerMin = 5
adminSetPasswordSendPerMin = 60
)
// handleAdminListUsers / handleAdminListCompanies live in admin_orgs_handlers.go.
// handleAdminReadiness returns cutover hypercare counts for platform admins (P1-15).
// GET /api/admin/readiness
//
// companies_without_api_keys counts tenants with zero non-revoked keys. Legacy
// api_keys were never ETL'd — this is the reissue inventory (not a fake migration).
func (s *Server) handleAdminReadiness(w http.ResponseWriter, r *http.Request) {
if s.Pool == nil {
Error(w, http.StatusServiceUnavailable, "database unavailable")
return
}
ctx := r.Context()
var mustSetPassword, withoutAdmin, withoutPlan, withoutAPIKeys int64
if err := s.Pool.QueryRow(ctx, `
SELECT
(SELECT COUNT(*) FROM users
WHERE must_set_password = true AND is_active = true),
(SELECT COUNT(*) FROM companies c
WHERE c.id <> $1
AND NOT EXISTS (
SELECT 1 FROM memberships m
WHERE m.company_id = c.id AND m.role = 'admin' AND m.status = 'active'
)),
(SELECT COUNT(*) FROM companies c
WHERE c.id <> $1
AND NOT EXISTS (
SELECT 1 FROM company_plans cp
WHERE cp.company_id = c.id AND cp.is_active = true
)),
(SELECT COUNT(*) FROM companies c
WHERE c.id <> $1
AND NOT EXISTS (
SELECT 1 FROM api_keys k
WHERE k.company_id = c.id AND k.revoked_at IS NULL
))
`, platformsettings.SystemCompanyID).Scan(&mustSetPassword, &withoutAdmin, &withoutPlan, &withoutAPIKeys); err != nil {
Error(w, http.StatusInternalServerError, "readiness counts failed")
return
}
JSON(w, http.StatusOK, map[string]any{
"must_set_password": mustSetPassword,
"companies_without_admin": withoutAdmin,
"companies_without_plan": withoutPlan,
"companies_without_api_keys": withoutAPIKeys,
})
}
func (s *Server) handleAdminListJobs(w http.ResponseWriter, r *http.Request) {
limit, offset := ParseLimitOffset(r)
rows, err := s.Pool.Query(r.Context(), `
SELECT id, company_id, status, total_products, processed_products, error, created_at, updated_at
FROM processing_jobs
ORDER BY created_at DESC LIMIT $1 OFFSET $2`, limit, offset)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
defer rows.Close()
type row struct {
ID uuid.UUID `json:"id"`
CompanyID uuid.UUID `json:"company_id"`
Status string `json:"status"`
TotalProducts int `json:"total_products"`
ProcessedProducts int `json:"processed_products"`
Error *string `json:"error"`
CreatedAt any `json:"created_at"`
UpdatedAt any `json:"updated_at"`
}
out := make([]row, 0)
for rows.Next() {
var j row
if err := rows.Scan(&j.ID, &j.CompanyID, &j.Status, &j.TotalProducts, &j.ProcessedProducts, &j.Error, &j.CreatedAt, &j.UpdatedAt); err != nil {
Error(w, http.StatusInternalServerError, "scan failed")
return
}
if j.Error != nil && *j.Error != "" {
redacted := processing.TruncateError(errors.New(*j.Error))
j.Error = &redacted
}
out = append(out, j)
}
JSON(w, http.StatusOK, map[string]any{"jobs": out, "limit": limit, "offset": offset})
}
func (s *Server) handleAdminStuckCleanup(w http.ResponseWriter, r *http.Request) {
res, err := processing.CleanupStuck(r.Context(), s.Pool)
if err != nil {
Error(w, http.StatusInternalServerError, "cleanup failed")
return
}
JSON(w, http.StatusOK, map[string]any{
"jobs_marked_failed": res.JobsMarkedFailed,
"products_reset": res.ProductsReset,
"sync_jobs_marked_failed": res.SyncJobsMarkedFailed,
})
}
func (s *Server) handleAdminOrphanProcessedReport(w http.ResponseWriter, r *http.Request) {
res, err := processing.ReportOrphanProcessed(r.Context(), s.Pool)
if err != nil {
Error(w, http.StatusInternalServerError, "orphan report failed")
return
}
JSON(w, http.StatusOK, res)
}
func (s *Server) handleAdminOrphanProcessedCleanup(w http.ResponseWriter, r *http.Request) {
confirm := r.URL.Query().Get("confirm") == "true"
var body struct {
Confirm bool `json:"confirm"`
}
if err := DecodeJSONOptional(r, &body); err == nil && body.Confirm {
confirm = true
}
res, err := processing.CleanupOrphanProcessed(r.Context(), s.Pool, confirm)
if err != nil {
if errors.Is(err, processing.ErrOrphanCleanupEmpty) ||
errors.Is(err, processing.ErrOrphanCleanupA1Protected) {
if msg, ok := processing.ClientError(err); ok {
Error(w, http.StatusConflict, msg)
return
}
}
Error(w, http.StatusInternalServerError, "orphan cleanup failed")
return
}
if !confirm {
JSON(w, http.StatusOK, map[string]any{
"ok": true,
"dry_run": true,
"deleted": 0,
"message": "pass confirm=true (query or JSON body) to delete; report only",
"report": res,
})
return
}
JSON(w, http.StatusOK, res)
}
func (s *Server) ensureAdminSetPasswordLimiters() {
s.adminSetPasswordOnce.Do(func() {
s.adminSetPasswordReqRL = newSlidingWindowLimiter(adminSetPasswordReqPerMin, time.Minute)
s.adminSetPasswordSendRL = newSlidingWindowLimiter(adminSetPasswordSendPerMin, time.Minute)
})
}
func (s *Server) handleAdminSendSetPasswordEmails(w http.ResponseWriter, r *http.Request) {
if s.Mail == nil || s.Auth == nil {
Error(w, http.StatusServiceUnavailable, "mailer unavailable")
return
}
adminID, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
s.ensureAdminSetPasswordLimiters()
reqKey := "admin-set-password:" + adminID.String()
if !s.adminSetPasswordReqRL.allow(reqKey) {
w.Header().Set("Retry-After", "60")
Error(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
var body struct {
UserID *uuid.UUID `json:"user_id"`
}
if err := DecodeJSONOptional(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
var targets []uuid.UUID
if body.UserID != nil {
targets = []uuid.UUID{*body.UserID}
} else {
users, err := s.Auth.ListUsersNeedingPassword(r.Context(), adminSetPasswordBulkLimit)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
for _, u := range users {
targets = append(targets, u.ID)
}
}
smtpOn := s.Mail.Enabled()
sent := 0
issued := 0
skippedSynthetic := 0
skippedIneligible := 0
skippedRateLimited := 0
skippedSend := 0
var singleToken string
singleUser := body.UserID != nil
for _, uid := range targets {
sendKey := "admin-set-password-send:" + adminID.String()
if !s.adminSetPasswordSendRL.allow(sendKey) {
skippedRateLimited++
if singleUser {
w.Header().Set("Retry-After", "60")
Error(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
continue
}
token, email, mode, err := s.issueSetPasswordDelivery(r.Context(), uid)
if err != nil {
switch {
case errors.Is(err, auth.ErrSyntheticEmail):
skippedSynthetic++
default:
skippedIneligible++
}
continue
}
issued++
var msg mail.Message
if mode == "invite" {
msg = mail.MigratedSetPasswordMessage(s.Config.WebOrigin, email, token)
} else {
msg = mail.SetPasswordMessage(s.Config.WebOrigin, email, token)
}
if err := s.Mail.Send(msg); err != nil {
log.Printf("admin set-password send failed user_id=%s", uid)
skippedSend++
continue
}
if smtpOn {
sent++
} else if singleUser {
// Share token only for single-user reissue when SMTP is off (no email in response).
singleToken = token
}
}
skipped := skippedSynthetic + skippedIneligible + skippedRateLimited + skippedSend
resp := map[string]any{
"sent": sent,
"issued": issued,
"skipped": skipped,
"skipped_synthetic": skippedSynthetic,
"skipped_ineligible": skippedIneligible,
"skipped_rate_limited": skippedRateLimited,
"skipped_send": skippedSend,
"smtp_enabled": smtpOn,
"mode": "invite",
}
if singleToken != "" {
resp["token"] = singleToken
}
JSON(w, http.StatusOK, resp)
}
// issueSetPasswordDelivery prefers a durable invite; falls back to HMAC when the user
// still needs a password but has no active membership. Never logs email or token.
func (s *Server) issueSetPasswordDelivery(ctx context.Context, userID uuid.UUID) (token, email, mode string, err error) {
inv, err := s.Auth.ReissueSetPasswordInvite(ctx, userID, 0)
if err == nil {
return inv.Token, inv.Email, "invite", nil
}
if errors.Is(err, auth.ErrSyntheticEmail) {
return "", "", "", err
}
if !errors.Is(err, auth.ErrNotEligibleSetPassword) && !errors.Is(err, auth.ErrUserNotFound) {
log.Printf("admin set-password invite failed user_id=%s", userID)
return "", "", "", err
}
u, gerr := s.Auth.GetUser(ctx, userID)
if gerr != nil || !u.MustSetPassword || !u.IsActive {
return "", "", "", auth.ErrNotEligibleSetPassword
}
if auth.IsSyntheticLegacyEmail(u.Email) {
return "", "", "", auth.ErrSyntheticEmail
}
token, terr := auth.IssueSetPasswordToken(s.Config.TokenSigningSecret, u.ID, 0)
if terr != nil {
log.Printf("admin hmac set-password token failed user_id=%s", userID)
return "", "", "", auth.ErrNotEligibleSetPassword
}
return token, u.Email, "hmac", nil
}
@@ -0,0 +1,79 @@
package httpapi
import (
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/campaigns"
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
)
// POST /api/admin/settings/mail/test — send a one-off SMTP probe using platform settings.
func (s *Server) handleAdminTestMail(w http.ResponseWriter, r *http.Request) {
if s.Mail == nil {
Error(w, http.StatusServiceUnavailable, "mailer unavailable")
return
}
var body struct {
To string `json:"to"`
}
if err := DecodeJSONOptional(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
to := strings.TrimSpace(body.To)
if to == "" {
if email, ok := s.sessionUserEmail(r.Context()); ok {
to = email
}
}
if to == "" {
Error(w, http.StatusBadRequest, "to is required")
return
}
normalized, err := campaigns.NormalizeEmail(to)
if err != nil {
Error(w, http.StatusBadRequest, "invalid email")
return
}
to = normalized
if s.PlatformSettings != nil {
if dry, err := s.PlatformSettings.ResolveEmailDryRun(r.Context()); err == nil && dry.DryRun {
JSON(w, http.StatusOK, map[string]any{
"status": "skipped",
"smtp_enabled": false,
"dry_run": true,
"message": "Email dry-run is on; disable dry-run in admin platform mail settings to send a real probe",
})
return
}
}
enabled := s.Mail.Enabled()
if !enabled {
JSON(w, http.StatusOK, map[string]any{
"status": "skipped",
"smtp_enabled": false,
"message": "SMTP is not configured in admin platform mail settings",
})
return
}
msg := mail.Message{
To: to,
Subject: "Descrybe SMTP test",
Text: "This is a Descrybe platform SMTP test message.",
HTML: "<p>This is a Descrybe platform SMTP test message.</p>",
}
if err := s.Mail.Send(msg); err != nil {
JSON(w, http.StatusOK, map[string]any{
"status": "failed",
"smtp_enabled": true,
"message": "SMTP send failed — check host/credentials in platform mail settings",
})
return
}
JSON(w, http.StatusOK, map[string]any{
"status": "ok",
"smtp_enabled": true,
"message": "Test message accepted by SMTP",
})
}
@@ -0,0 +1,213 @@
package httpapi
import (
"net/http"
"strconv"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/google/uuid"
)
// handleAdminListUsers returns a paginated user directory for platform admins.
// Query: limit, offset, q|search, staff_only, active_only, inactive_only.
func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
limit, offset := ParseLimitOffset(r)
qSearch := QuerySearch(r)
staffOnly := QueryTruthy(r, "staff_only")
activeOnly := QueryTruthy(r, "active_only")
inactiveOnly := QueryTruthy(r, "inactive_only")
where := "WHERE 1=1"
args := make([]any, 0, 6)
next := 1
addArg := func(v any) string {
args = append(args, v)
placeholder := "$" + strconv.Itoa(next)
next++
return placeholder
}
if staffOnly {
where += " AND (is_platform_admin = true OR staff_role IS NOT NULL)"
}
if activeOnly && !inactiveOnly {
where += " AND is_active = true"
}
if inactiveOnly && !activeOnly {
where += " AND is_active = false"
}
if qSearch != "" {
p := addArg("%" + qSearch + "%")
where += " AND (email ILIKE " + p + " OR COALESCE(name, '') ILIKE " + p + ")"
}
var total int
if err := s.Pool.QueryRow(r.Context(), "SELECT COUNT(*) FROM users "+where, args...).Scan(&total); err != nil {
Error(w, http.StatusInternalServerError, "count failed")
return
}
limitP := addArg(limit)
offsetP := addArg(offset)
rows, err := s.Pool.Query(r.Context(), `
SELECT id, email, name, must_set_password, is_platform_admin, staff_role, is_active, created_at
FROM users `+where+`
ORDER BY created_at DESC LIMIT `+limitP+` OFFSET `+offsetP, args...)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
defer rows.Close()
type row struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Name *string `json:"name"`
MustSetPassword bool `json:"must_set_password"`
IsPlatformAdmin bool `json:"is_platform_admin"`
StaffRole *string `json:"staff_role,omitempty"`
ResolvedRole string `json:"resolved_role,omitempty"`
IsActive bool `json:"is_active"`
CreatedAt any `json:"created_at"`
}
out := make([]row, 0)
for rows.Next() {
var u row
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.MustSetPassword, &u.IsPlatformAdmin, &u.StaffRole, &u.IsActive, &u.CreatedAt); err != nil {
Error(w, http.StatusInternalServerError, "scan failed")
return
}
stored := ""
if u.StaffRole != nil {
stored = *u.StaffRole
}
u.ResolvedRole = auth.ResolveStaffRole(u.IsPlatformAdmin, stored)
out = append(out, u)
}
if err := rows.Err(); err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{
"users": out,
"total": total,
"limit": limit,
"offset": offset,
})
}
// handleAdminListCompanies returns paginated companies with active plan summary.
// Query: limit, offset, q|search, without_active_plan, without_api_keys.
// without_api_keys filters tenants with no non-revoked keys (cutover reissue inventory).
func (s *Server) handleAdminListCompanies(w http.ResponseWriter, r *http.Request) {
limit, offset := ParseLimitOffset(r)
withoutPlan := QueryTruthy(r, "without_active_plan")
withoutAPIKeys := QueryTruthy(r, "without_api_keys")
qSearch := QuerySearch(r)
where := "WHERE c.id <> $1"
args := []any{platformsettings.SystemCompanyID}
next := 2
addArg := func(v any) string {
args = append(args, v)
placeholder := "$" + strconv.Itoa(next)
next++
return placeholder
}
if withoutPlan {
where += `
AND NOT EXISTS (
SELECT 1 FROM company_plans cp0
WHERE cp0.company_id = c.id AND cp0.is_active = true
)`
}
if withoutAPIKeys {
where += `
AND NOT EXISTS (
SELECT 1 FROM api_keys k0
WHERE k0.company_id = c.id AND k0.revoked_at IS NULL
)`
}
if qSearch != "" {
p := addArg("%" + qSearch + "%")
where += " AND (c.name ILIKE " + p + " OR c.id::text ILIKE " + p + ")"
}
var total int
if err := s.Pool.QueryRow(r.Context(), "SELECT COUNT(*) FROM companies c "+where, args...).Scan(&total); err != nil {
Error(w, http.StatusInternalServerError, "count failed")
return
}
limitP := addArg(limit)
offsetP := addArg(offset)
q := `
SELECT c.id, c.name, c.language, c.created_at,
COALESCE(cb.total_credits, 0), COALESCE(cb.used_credits, 0),
cp.plan_id IS NOT NULL AS has_active_plan,
cp.plan_id, p.name, COALESCE(p.is_custom, false),
EXISTS (
SELECT 1 FROM api_keys k
WHERE k.company_id = c.id AND k.revoked_at IS NULL
) AS has_api_key
FROM companies c
LEFT JOIN credit_balances cb ON cb.company_id = c.id
LEFT JOIN company_plans cp ON cp.company_id = c.id AND cp.is_active = true
LEFT JOIN plans p ON p.id = cp.plan_id
` + where + `
ORDER BY c.created_at DESC LIMIT ` + limitP + ` OFFSET ` + offsetP
rows, err := s.Pool.Query(r.Context(), q, args...)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
defer rows.Close()
type row struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
CreatedAt any `json:"created_at"`
TotalCredits int `json:"total_credits"`
UsedCredits int `json:"used_credits"`
HasActivePlan bool `json:"has_active_plan"`
PlanID *int64 `json:"plan_id,omitempty"`
PlanName *string `json:"plan_name,omitempty"`
PlanIsCustom bool `json:"plan_is_custom,omitempty"`
PlanIsLegacy bool `json:"plan_is_legacy,omitempty"`
HasAPIKey bool `json:"has_api_key"`
}
out := make([]row, 0)
for rows.Next() {
var c row
var planID *int64
var planName *string
var isCustom bool
if err := rows.Scan(&c.ID, &c.Name, &c.Language, &c.CreatedAt, &c.TotalCredits, &c.UsedCredits, &c.HasActivePlan, &planID, &planName, &isCustom, &c.HasAPIKey); err != nil {
Error(w, http.StatusInternalServerError, "scan failed")
return
}
c.PlanID = planID
c.PlanName = planName
c.PlanIsCustom = isCustom
if planName != nil {
c.PlanIsLegacy = billing.IsLegacyPlan(*planName, false)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{
"companies": out,
"total": total,
"limit": limit,
"offset": offset,
"without_active_plan": withoutPlan,
"without_api_keys": withoutAPIKeys,
})
}
@@ -0,0 +1,89 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/google/uuid"
)
func TestHandleAdminReadinessNilPool(t *testing.T) {
t.Parallel()
s := &Server{}
req := httptest.NewRequest(http.MethodGet, "/api/admin/readiness", nil)
rec := httptest.NewRecorder()
s.handleAdminReadiness(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
}
}
// TestRouterAdminReadinessMounted locks the P1-15 SPA contract: after session +
// platform-admin gates, GET /api/admin/readiness must reach the handler (503 with
// nil pool), not chi 404. Unauthed probes alone cannot prove the mount — any
// /api/admin/* returns 401 from RequireSession whether or not /readiness exists.
func TestRouterAdminReadinessMounted(t *testing.T) {
t.Parallel()
sm := scs.New()
sm.Cookie.Name = "descrybe_session"
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{
Config: config.Config{
CSRFCookieName: "descrybe_csrf",
WebOrigin: "http://localhost:5173",
},
Sessions: sm,
Auth: &auth.Service{},
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
return got == uid, nil
},
}
var token string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
token = c.Value
}
}
if token == "" {
t.Fatal("expected session cookie from seed request")
}
h := s.Router()
unauth := httptest.NewRecorder()
h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/admin/readiness", nil))
if unauth.Code != http.StatusUnauthorized {
t.Fatalf("unauth status=%d want 401 body=%s", unauth.Code, unauth.Body.String())
}
mounted := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/admin/readiness", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
h.ServeHTTP(mounted, req)
if mounted.Code == http.StatusNotFound {
t.Fatalf("readiness not mounted: status=404 body=%s", mounted.Body.String())
}
if mounted.Code != http.StatusServiceUnavailable {
t.Fatalf("mounted status=%d want 503 (nil pool) body=%s", mounted.Code, mounted.Body.String())
}
missing := httptest.NewRecorder()
missReq := httptest.NewRequest(http.MethodGet, "/api/admin/does-not-exist", nil)
missReq.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
h.ServeHTTP(missing, missReq)
if missing.Code != http.StatusNotFound {
t.Fatalf("unknown admin path status=%d want 404 body=%s", missing.Code, missing.Body.String())
}
}
@@ -0,0 +1,117 @@
package httpapi
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
"github.com/google/uuid"
)
type recordingMailer struct {
enabled bool
sent []mail.Message
err error
}
func (m *recordingMailer) Enabled() bool { return m.enabled }
func (m *recordingMailer) Send(msg mail.Message) error {
if m.err != nil {
return m.err
}
m.sent = append(m.sent, msg)
return nil
}
func TestHandleAdminSendSetPasswordEmailsUnauthorized(t *testing.T) {
t.Parallel()
s := &Server{Mail: &recordingMailer{enabled: true}, Auth: &auth.Service{}}
req := httptest.NewRequest(http.MethodPost, "/api/admin/emails/set-password", bytes.NewBufferString("{}"))
rec := httptest.NewRecorder()
s.handleAdminSendSetPasswordEmails(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status=%d want 401", rec.Code)
}
}
func TestHandleAdminSendSetPasswordEmailsMailerRequired(t *testing.T) {
t.Parallel()
adminID := uuid.New()
s := &Server{Auth: &auth.Service{}}
ctx := context.WithValue(context.Background(), ctxUserID, adminID)
req := httptest.NewRequest(http.MethodPost, "/api/admin/emails/set-password", bytes.NewBufferString("{}"))
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
s.handleAdminSendSetPasswordEmails(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503", rec.Code)
}
}
func TestHandleAdminSendSetPasswordEmailsRateLimited(t *testing.T) {
t.Parallel()
adminID := uuid.New()
s := &Server{
Mail: &recordingMailer{enabled: true},
Auth: &auth.Service{},
}
s.ensureAdminSetPasswordLimiters()
s.adminSetPasswordReqRL = newSlidingWindowLimiter(1, time.Minute)
reqKey := "admin-set-password:" + adminID.String()
if !s.adminSetPasswordReqRL.allow(reqKey) {
t.Fatal("setup: expected first allow")
}
ctx := context.WithValue(context.Background(), ctxUserID, adminID)
req := httptest.NewRequest(http.MethodPost, "/api/admin/emails/set-password", bytes.NewBufferString("{}"))
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
s.handleAdminSendSetPasswordEmails(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("status=%d want 429 body=%s", rec.Code, rec.Body.String())
}
if rec.Header().Get("Retry-After") == "" {
t.Fatal("expected Retry-After header")
}
raw := rec.Body.String()
if strings.Contains(raw, "@") {
t.Fatalf("rate-limit response must not include email addresses: %s", raw)
}
}
func TestHandleAdminSendSetPasswordEmailsSendRateLimitedSingleUser(t *testing.T) {
t.Parallel()
adminID := uuid.New()
targetID := uuid.New()
s := &Server{
Mail: &recordingMailer{enabled: true},
Auth: &auth.Service{},
}
s.ensureAdminSetPasswordLimiters()
s.adminSetPasswordReqRL = newSlidingWindowLimiter(10, time.Minute)
s.adminSetPasswordSendRL = newSlidingWindowLimiter(1, time.Minute)
sendKey := "admin-set-password-send:" + adminID.String()
if !s.adminSetPasswordSendRL.allow(sendKey) {
t.Fatal("setup: expected first send allow")
}
body := `{"user_id":"` + targetID.String() + `"}`
ctx := context.WithValue(context.Background(), ctxUserID, adminID)
req := httptest.NewRequest(http.MethodPost, "/api/admin/emails/set-password", bytes.NewBufferString(body))
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
s.handleAdminSendSetPasswordEmails(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("status=%d want 429 body=%s", rec.Code, rec.Body.String())
}
if rec.Header().Get("Retry-After") == "" {
t.Fatal("expected Retry-After header")
}
}
@@ -0,0 +1,99 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/google/uuid"
)
// TestRouterAdminSettingsAIConfigPresent locks GET /api/admin/settings AI surface:
// legacy openai block always; multi-role ai_roles with all catalog roles masked.
func TestRouterAdminSettingsAIConfigPresent(t *testing.T) {
t.Parallel()
sm := scs.New()
sm.Cookie.Name = "descrybe_session"
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{
Config: config.Config{
CSRFCookieName: "descrybe_csrf",
WebOrigin: "http://localhost:5173",
},
Sessions: sm,
Auth: &auth.Service{},
PlatformSettings: platformsettings.NewService(nil, platformsettings.EnvConfig{}),
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
return got == uid, nil
},
}
var token string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
token = c.Value
}
}
if token == "" {
t.Fatal("expected session cookie from seed request")
}
h := s.Router()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d want 200 body=%s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v body=%s", err, rec.Body.String())
}
openai, ok := body["openai"].(map[string]any)
if !ok {
t.Fatalf("missing openai object: %s", rec.Body.String())
}
for _, key := range []string{"configured", "has_api_key", "source"} {
if _, ok := openai[key]; !ok {
t.Fatalf("openai missing %q: %#v", key, openai)
}
}
if raw, exists := openai["api_key"]; exists && raw != nil && raw != "" {
t.Fatalf("openai must not leak api_key, got %#v", raw)
}
rawConfigs, hasConfigs := body["ai_roles"]
if !hasConfigs || rawConfigs == nil {
t.Fatalf("ai_roles missing body=%s", rec.Body.String())
}
configs, ok := rawConfigs.(map[string]any)
if !ok {
t.Fatalf("ai_roles type=%T want object body=%s", rawConfigs, rec.Body.String())
}
for _, role := range platformsettings.AIRoles {
slot, ok := configs[role].(map[string]any)
if !ok {
t.Fatalf("ai_roles missing role %q: %#v", role, configs)
}
if slot["role"] != role {
t.Fatalf("role %q slot.role=%v", role, slot["role"])
}
if raw, exists := slot["api_key"]; exists && raw != nil && raw != "" {
t.Fatalf("ai_roles.%s must not leak api_key", role)
}
}
}
@@ -0,0 +1,49 @@
package httpapi
import (
"net/http"
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
)
// GET /api/admin/settings — platform integration config (secrets masked).
func (s *Server) handleGetAdminSettings(w http.ResponseWriter, r *http.Request) {
if s.PlatformSettings == nil {
Error(w, http.StatusServiceUnavailable, "platform settings unavailable")
return
}
view, err := s.PlatformSettings.GetPublic(r.Context())
if err != nil {
Error(w, http.StatusInternalServerError, "failed to load platform settings")
return
}
JSON(w, http.StatusOK, view)
}
// PUT /api/admin/settings — partial update; omit secrets to keep existing.
func (s *Server) handlePutAdminSettings(w http.ResponseWriter, r *http.Request) {
if s.PlatformSettings == nil {
Error(w, http.StatusServiceUnavailable, "platform settings unavailable")
return
}
var body platformsettings.UpdateInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
view, err := s.PlatformSettings.Update(r.Context(), body)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update platform settings", err, platformsettings.ClientError)
return
}
// OpenAI / ai_roles / SMTP / OAuth / Stripe / EPREL resolve at use time — no client cache to drop.
// Feed private allowlist is process-global; refresh immediately after a successful PUT.
if body.Values != nil {
if _, ok := body.Values[platformsettings.KeyFeedPrivateAllowlist]; ok {
csv, _ := s.PlatformSettings.ResolveFeedPrivateAllowlist(r.Context())
feeds.ApplyPrivateAllowlistCSV(csv)
}
}
JSON(w, http.StatusOK, view)
}
@@ -0,0 +1,70 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/google/uuid"
)
// TestRouterAdminSettingsMounted locks GET /api/admin/settings after session +
// platform-admin gates (503 with nil pool / nil service path, not chi 404).
func TestRouterAdminSettingsMounted(t *testing.T) {
t.Parallel()
sm := scs.New()
sm.Cookie.Name = "descrybe_session"
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{
Config: config.Config{
CSRFCookieName: "descrybe_csrf",
WebOrigin: "http://localhost:5173",
},
Sessions: sm,
Auth: &auth.Service{},
PlatformSettings: platformsettings.NewService(nil, platformsettings.EnvConfig{}),
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
return got == uid, nil
},
}
var token string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
token = c.Value
}
}
if token == "" {
t.Fatal("expected session cookie from seed request")
}
h := s.Router()
unauth := httptest.NewRecorder()
h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil))
if unauth.Code != http.StatusUnauthorized {
t.Fatalf("unauth status=%d want 401 body=%s", unauth.Code, unauth.Body.String())
}
mounted := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
h.ServeHTTP(mounted, req)
if mounted.Code == http.StatusNotFound {
t.Fatalf("settings not mounted: status=404 body=%s", mounted.Body.String())
}
if mounted.Code != http.StatusOK {
t.Fatalf("mounted status=%d want 200 body=%s", mounted.Code, mounted.Body.String())
}
}
@@ -0,0 +1,140 @@
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/support"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// handleAdminListStaff returns platform staff users (admin|developer only).
// GET /api/admin/staff
func (s *Server) handleAdminListStaff(w http.ResponseWriter, r *http.Request) {
if s.Auth == nil {
Error(w, http.StatusServiceUnavailable, "auth unavailable")
return
}
limit, offset := ParseLimitOffset(r)
users, err := s.Auth.ListStaffUsers(r.Context(), limit, offset)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{
"staff": users,
"limit": limit,
"offset": offset,
})
}
// handleAdminSetStaffRole assigns or clears a platform staff role (admin|developer only).
// PATCH /api/admin/users/{id}/staff-role
// Body: {"staff_role":"admin"|"developer"|"support_staff"|null}
func (s *Server) handleAdminSetStaffRole(w http.ResponseWriter, r *http.Request) {
if s.Auth == nil {
Error(w, http.StatusServiceUnavailable, "auth unavailable")
return
}
actorID, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
targetID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if targetID == actorID {
Error(w, http.StatusForbidden, "cannot change own staff role")
return
}
var body struct {
StaffRole *string `json:"staff_role"`
}
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
role := ""
if body.StaffRole != nil {
role = strings.TrimSpace(*body.StaffRole)
}
user, err := s.Auth.SetStaffRole(r.Context(), targetID, role)
if err != nil {
if errors.Is(err, auth.ErrInvalidStaffRole) {
Error(w, http.StatusBadRequest, "invalid staff_role")
return
}
if errors.Is(err, auth.ErrStaffUserNotFound) {
Error(w, http.StatusNotFound, "user not found")
return
}
Error(w, http.StatusInternalServerError, "update failed")
return
}
JSON(w, http.StatusOK, map[string]any{
"user": user,
"staff_capabilities": auth.StaffCapabilities(user.ResolvedRole),
})
}
// handleAdminSetSupportAgent grants or revokes support_staff only (full-admin exclusive).
// PUT /api/admin/support/agents/{id}
// Body: {"enabled": true|false} or {"is_support_agent": true|false}
func (s *Server) handleAdminSetSupportAgent(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
actorID, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
targetID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if targetID == actorID {
Error(w, http.StatusForbidden, "cannot change own support agent flag")
return
}
var body struct {
Enabled *bool `json:"enabled"`
IsSupportAgent *bool `json:"is_support_agent"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
enable := false
switch {
case body.Enabled != nil:
enable = *body.Enabled
case body.IsSupportAgent != nil:
enable = *body.IsSupportAgent
default:
Error(w, http.StatusBadRequest, "enabled required")
return
}
agent, err := s.Support.SetSupportAgent(r.Context(), targetID, enable)
if errors.Is(err, support.ErrNotFound) {
Error(w, http.StatusNotFound, "user not found")
return
}
if err != nil {
LogAndError(w, http.StatusInternalServerError, "update failed", err)
return
}
JSON(w, http.StatusOK, map[string]any{"agent": agent})
}
@@ -0,0 +1,50 @@
package httpapi
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func TestHandleAdminSetStaffRoleRejectsSelf(t *testing.T) {
t.Parallel()
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{Auth: &auth.Service{}}
req := httptest.NewRequest(http.MethodPatch, "/api/admin/users/"+uid.String()+"/staff-role",
bytes.NewBufferString(`{"staff_role":"support_staff"}`))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", uid.String())
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
s.handleAdminSetStaffRole(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d body=%s, want 403", rec.Code, rec.Body.String())
}
}
func TestHandleAdminSetStaffRoleInvalidJSON(t *testing.T) {
t.Parallel()
actor := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
target := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
s := &Server{Auth: &auth.Service{}}
req := httptest.NewRequest(http.MethodPatch, "/api/admin/users/"+target.String()+"/staff-role",
bytes.NewBufferString(`{`))
ctx := context.WithValue(context.Background(), ctxUserID, actor)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", target.String())
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
s.handleAdminSetStaffRole(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
@@ -0,0 +1,139 @@
package httpapi
import (
"context"
"errors"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
var errStoreReconnectPoolUnavailable = errors.New("database unavailable")
// StoreReconnectGap is one connected-but-invalid store connector for a tenant.
// "Invalid" matches merchant needsStoreReconnect: store identity exists but secrets are missing.
type StoreReconnectGap struct {
CompanyID uuid.UUID `json:"company_id"`
CompanyName string `json:"company_name"`
Channel string `json:"channel"`
Identity string `json:"identity"`
IsEnabled bool `json:"is_enabled"`
Reason string `json:"reason"`
LastTestStatus string `json:"last_test_status,omitempty"`
}
// StoreReconnectInventory is the admin list payload for credential-gap stores.
type StoreReconnectInventory struct {
Stores []StoreReconnectGap `json:"stores"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
const storeReconnectReasonMissingCredentials = "missing_credentials"
// ListStoreReconnectGaps returns companies with Woo/Shopify identity but no usable credential blobs.
// Presence-only (no decrypt) — same honesty bar as has_credentials=false in GetConfig for empty secrets.
func ListStoreReconnectGaps(ctx context.Context, pool *pgxpool.Pool, limit, offset int) (StoreReconnectInventory, error) {
out := StoreReconnectInventory{
Stores: []StoreReconnectGap{},
Limit: limit,
Offset: offset,
}
if pool == nil {
return out, errStoreReconnectPoolUnavailable
}
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
out.Limit = limit
out.Offset = offset
const q = `
WITH gaps AS (
SELECT c.id AS company_id, c.name AS company_name,
'shopify'::text AS channel,
NULLIF(BTRIM(sc.shop_domain), '') AS identity,
sc.is_enabled,
COALESCE(sc.last_test_status, '') AS last_test_status
FROM companies c
JOIN shopify_configs sc ON sc.company_id = c.id
WHERE c.id <> $1
AND NULLIF(BTRIM(sc.shop_domain), '') IS NOT NULL
AND COALESCE(LENGTH(sc.access_token), 0) = 0
AND COALESCE(NULLIF(BTRIM(sc.sync_options->>'client_id'), ''), '') = ''
AND COALESCE(NULLIF(BTRIM(sc.sync_options->>'client_secret_enc'), ''), '') = ''
UNION ALL
SELECT c.id, c.name, 'woocommerce',
NULLIF(BTRIM(wc.store_url), ''),
wc.is_enabled,
COALESCE(wc.last_test_status, '')
FROM companies c
JOIN woocommerce_configs wc ON wc.company_id = c.id
WHERE c.id <> $1
AND NULLIF(BTRIM(wc.store_url), '') IS NOT NULL
AND (
COALESCE(LENGTH(wc.consumer_key), 0) = 0
OR COALESCE(LENGTH(wc.consumer_secret), 0) = 0
)
)
SELECT COUNT(*) OVER() AS total,
company_id, company_name, channel, identity, is_enabled, last_test_status
FROM gaps
ORDER BY company_name ASC, channel ASC
LIMIT $2 OFFSET $3`
rows, err := pool.Query(ctx, q, platformsettings.SystemCompanyID, limit, offset)
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var row StoreReconnectGap
var total int
if err := rows.Scan(
&total,
&row.CompanyID,
&row.CompanyName,
&row.Channel,
&row.Identity,
&row.IsEnabled,
&row.LastTestStatus,
); err != nil {
return out, err
}
row.Reason = storeReconnectReasonMissingCredentials
row.Identity = strings.TrimSpace(row.Identity)
row.LastTestStatus = strings.TrimSpace(row.LastTestStatus)
out.Total = total
out.Stores = append(out.Stores, row)
}
if err := rows.Err(); err != nil {
return out, err
}
return out, nil
}
func (s *Server) handleAdminListStoreReconnectGaps(w http.ResponseWriter, r *http.Request) {
if s.Pool == nil {
Error(w, http.StatusServiceUnavailable, "database unavailable")
return
}
limit, offset := ParseLimitOffset(r)
inv, err := ListStoreReconnectGaps(r.Context(), s.Pool, limit, offset)
if err != nil {
Error(w, http.StatusInternalServerError, "store reconnect inventory failed")
return
}
JSON(w, http.StatusOK, inv)
}
@@ -0,0 +1,90 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/google/uuid"
)
func TestHandleAdminListStoreReconnectGapsNilPool(t *testing.T) {
t.Parallel()
s := &Server{}
req := httptest.NewRequest(http.MethodGet, "/api/admin/stores/reconnect-needed", nil)
rec := httptest.NewRecorder()
s.handleAdminListStoreReconnectGaps(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
}
}
func TestListStoreReconnectGapsNilPool(t *testing.T) {
t.Parallel()
inv, err := ListStoreReconnectGaps(context.Background(), nil, 10, 0)
if err == nil {
t.Fatal("expected error for nil pool")
}
if inv.Stores == nil {
t.Fatal("expected non-nil stores slice")
}
}
// TestRouterAdminStoreReconnectMounted locks GET /api/admin/stores/reconnect-needed
// after session + platform-admin (503 with nil pool), not chi 404.
func TestRouterAdminStoreReconnectMounted(t *testing.T) {
t.Parallel()
sm := scs.New()
sm.Cookie.Name = "descrybe_session"
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{
Config: config.Config{
CSRFCookieName: "descrybe_csrf",
WebOrigin: "http://localhost:5173",
},
Sessions: sm,
Auth: &auth.Service{},
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
return got == uid, nil
},
}
var token string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
token = c.Value
}
}
if token == "" {
t.Fatal("expected session cookie from seed request")
}
h := s.Router()
unauth := httptest.NewRecorder()
h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/admin/stores/reconnect-needed", nil))
if unauth.Code != http.StatusUnauthorized {
t.Fatalf("unauth status=%d want 401 body=%s", unauth.Code, unauth.Body.String())
}
mounted := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/admin/stores/reconnect-needed", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
h.ServeHTTP(mounted, req)
if mounted.Code == http.StatusNotFound {
t.Fatalf("reconnect-needed not mounted: status=404 body=%s", mounted.Body.String())
}
if mounted.Code != http.StatusServiceUnavailable {
t.Fatalf("mounted status=%d want 503 (nil pool) body=%s", mounted.Code, mounted.Body.String())
}
}
@@ -0,0 +1,75 @@
package httpapi
import (
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
)
// POST /api/admin/settings/stripe/sync-credit-packs
// Creates/updates Stripe Products + one-time Prices for DefaultCreditPacks using
// the configured secret key (sk_test_* or sk_live_*), then writes Price IDs into
// platform settings (stripe.price.pack.*).
func (s *Server) handleAdminSyncStripeCreditPacks(w http.ResponseWriter, r *http.Request) {
if s.PlatformSettings == nil {
Error(w, http.StatusServiceUnavailable, "platform settings unavailable")
return
}
if s.Stripe == nil {
Error(w, http.StatusServiceUnavailable, "stripe unavailable")
return
}
cfg, err := s.PlatformSettings.ResolveStripe(r.Context(), s.Stripe.Cfg)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to resolve stripe settings")
return
}
secret := strings.TrimSpace(cfg.SecretKey)
if secret == "" || cfg.ForceMock {
Error(w, http.StatusBadRequest, "configure a Stripe secret key (test or live) and turn mock off before syncing")
return
}
mode := "live"
if strings.HasPrefix(secret, "sk_test_") {
mode = "test"
} else if !strings.HasPrefix(secret, "sk_live_") {
mode = "unknown"
}
svc := &billing.StripeService{
Pool: s.Stripe.Pool,
Cfg: billing.StripeConfig{SecretKey: secret},
}
results, err := svc.SyncCreditPackProducts(r.Context())
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not sync credit packs to Stripe", err, billing.ClientError)
return
}
written := make([]map[string]any, 0, len(results))
for _, row := range results {
key := billing.CreditPackSettingsKey(row.PackID)
if err := s.PlatformSettings.SetKV(r.Context(), key, row.PriceID); err != nil {
ClientOrLog(w, http.StatusBadRequest, "synced Stripe but failed to save price id", err, billing.ClientError)
return
}
written = append(written, map[string]any{
"pack_id": row.PackID,
"product_id": row.ProductID,
"price_id": row.PriceID,
"credits": row.Credits,
"price_usd": row.PriceUSD,
"created": row.Created,
"settings_key": key,
})
}
JSON(w, http.StatusOK, map[string]any{
"mode": mode,
"packs": written,
"message": "Credit pack Products/Prices synced; Price IDs saved to settings.",
})
}
+115
View File
@@ -0,0 +1,115 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
)
func (s *Server) handleGetAIIntegration(w http.ResponseWriter, r *http.Request) {
if s.AI == nil {
Error(w, http.StatusServiceUnavailable, "ai integration unavailable")
return
}
cid, _ := CompanyIDFromContext(r.Context())
cfg, err := s.AI.GetConfig(r.Context(), cid)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to load ai settings")
return
}
JSON(w, http.StatusOK, cfg)
}
func (s *Server) handlePutAIIntegration(w http.ResponseWriter, r *http.Request) {
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
return
}
if s.AI == nil {
Error(w, http.StatusServiceUnavailable, "ai integration unavailable")
return
}
cid, _ := CompanyIDFromContext(r.Context())
var body aiprovider.UpdateInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
cfg, err := s.AI.UpdateConfig(r.Context(), cid, body)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update ai settings", err, aiprovider.ClientError)
return
}
JSON(w, http.StatusOK, cfg)
}
func (s *Server) handleTestAIIntegration(w http.ResponseWriter, r *http.Request) {
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
return
}
if s.AI == nil {
Error(w, http.StatusServiceUnavailable, "ai integration unavailable")
return
}
cid, _ := CompanyIDFromContext(r.Context())
result, err := s.AI.TestConnection(r.Context(), cid)
if errors.Is(err, aiprovider.ErrNotConfigured) {
Error(w, http.StatusBadRequest, "ai provider not configured")
return
}
if err != nil {
// Safe message only — never echo provider error bodies (may contain key fragments).
JSON(w, http.StatusOK, result)
return
}
JSON(w, http.StatusOK, result)
}
func (s *Server) handleGetAIPrompts(w http.ResponseWriter, r *http.Request) {
if s.AIPrompts == nil {
Error(w, http.StatusServiceUnavailable, "ai prompts unavailable")
return
}
cid, _ := CompanyIDFromContext(r.Context())
lang := strings.TrimSpace(r.URL.Query().Get("language"))
if lang == "" {
lang = company.LoadLanguage(r.Context(), s.Pool, cid)
}
bundle, err := s.AIPrompts.GetBundle(r.Context(), cid, lang)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to load ai prompts")
return
}
JSON(w, http.StatusOK, bundle)
}
func (s *Server) handlePutAIPrompts(w http.ResponseWriter, r *http.Request) {
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
return
}
if s.AIPrompts == nil {
Error(w, http.StatusServiceUnavailable, "ai prompts unavailable")
return
}
cid, _ := CompanyIDFromContext(r.Context())
var body aiprompts.UpdateInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
bundle, err := s.AIPrompts.Update(r.Context(), cid, body)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update ai prompts", err, aiprompts.ClientError)
return
}
JSON(w, http.StatusOK, bundle)
}
@@ -0,0 +1,112 @@
package httpapi
import (
"net/http"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
limit, offset := ParseLimitOffset(r)
const where = "company_id = $1 AND revoked_at IS NULL"
var total int64
if err := s.Pool.QueryRow(r.Context(), "SELECT count(*) FROM api_keys WHERE "+where, cid).Scan(&total); err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
rows, err := s.Pool.Query(r.Context(), `
SELECT id, name, key_prefix, last_used_at, created_at
FROM api_keys WHERE `+where+`
ORDER BY created_at DESC LIMIT $2 OFFSET $3`, cid, limit, offset)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
defer rows.Close()
out := make([]map[string]any, 0)
for rows.Next() {
var id uuid.UUID
var name *string
var prefix string
var lastUsed, created any
if err := rows.Scan(&id, &name, &prefix, &lastUsed, &created); err != nil {
Error(w, http.StatusInternalServerError, "scan failed")
return
}
out = append(out, map[string]any{
"id": id, "name": name, "key_prefix": prefix, "last_used_at": lastUsed, "created_at": created,
})
}
JSON(w, http.StatusOK, map[string]any{"api_keys": out, "total": total, "limit": limit, "offset": offset})
}
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
uid, _ := UserIDFromContext(r.Context())
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
if !s.requireFeatures(w, r, "settings.api_keys", "capability.api_access") {
return
}
var body struct {
Name string `json:"name"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
raw, err := auth.RandomToken(24)
if err != nil {
Error(w, http.StatusInternalServerError, "key gen failed")
return
}
full := "dk_" + raw
prefix := full[:10]
var id uuid.UUID
err = s.Pool.QueryRow(r.Context(), `
INSERT INTO api_keys (company_id, user_id, name, key_hash, key_prefix)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
cid, uid, nullIfEmpty(body.Name), auth.HashAPIKey(full), prefix).Scan(&id)
if err != nil {
Error(w, http.StatusInternalServerError, "create failed")
return
}
JSON(w, http.StatusCreated, map[string]any{
"id": id, "name": body.Name, "key": full, "key_prefix": prefix,
})
}
func (s *Server) handleRevokeAPIKey(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
tag, err := s.Pool.Exec(r.Context(), `
UPDATE api_keys SET revoked_at = now(), updated_at = now()
WHERE id = $1 AND company_id = $2 AND revoked_at IS NULL`, id, cid)
if err != nil {
Error(w, http.StatusInternalServerError, "revoke failed")
return
}
if tag.RowsAffected() == 0 {
Error(w, http.StatusNotFound, "not found")
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
+427
View File
@@ -0,0 +1,427 @@
package httpapi
import (
"context"
"errors"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/google/uuid"
)
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
CompanyName string `json:"company_name"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
res, err := s.Auth.Register(r.Context(), auth.RegisterInput{
Email: body.Email, Password: body.Password, Name: body.Name, CompanyName: body.CompanyName,
})
if err != nil {
if errors.Is(err, auth.ErrUserExists) {
FieldError(w, http.StatusConflict, "user already exists", "user_already_exists", map[string]string{
"email": "user already exists",
})
return
}
if errors.Is(err, auth.ErrPasswordTooShort) {
FieldError(w, http.StatusBadRequest, "password must be at least 8 characters", "password_too_short", map[string]string{
"password": "password must be at least 8 characters",
})
return
}
if errors.Is(err, auth.ErrRegisterFieldsRequired) {
FieldError(w, http.StatusBadRequest, "email, password, and company name are required", "register_fields_required", map[string]string{
"email": "email, password, and company name are required",
"password": "email, password, and company name are required",
"company_name": "email, password, and company name are required",
})
return
}
ClientOrLog(w, http.StatusBadRequest, "registration failed", err, auth.ClientError)
return
}
_ = s.Billing.ProvisionFreePlan(r.Context(), res.CompanyID)
if err := s.beginAuthenticatedSession(r.Context(), res.User.ID, res.CompanyID); err != nil {
Error(w, http.StatusInternalServerError, "session start failed")
return
}
JSON(w, http.StatusCreated, res)
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
lockout := s.loginAttempts()
if locked, retryAfter := lockout.locked(body.Email); locked {
writeRateLimited(w, loginLockoutMaxFails, retryAfter)
return
}
res, err := s.Auth.Login(r.Context(), body.Email, body.Password)
if errors.Is(err, auth.ErrMustSetPassword) {
JSON(w, http.StatusForbidden, map[string]string{
"error": "password_not_set",
"code": "password_not_set",
"message": PublicMessage(w, "This account still needs a password. Open your set-password invite link, or ask a company admin to re-issue one to this email."),
})
return
}
if errors.Is(err, auth.ErrInvalidCredentials) {
lockout.recordFailure(body.Email)
if locked, retryAfter := lockout.locked(body.Email); locked {
writeRateLimited(w, loginLockoutMaxFails, retryAfter)
return
}
FieldError(w, http.StatusUnauthorized, "invalid credentials", "invalid_credentials", map[string]string{
"email": "invalid credentials",
"password": "invalid credentials",
})
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "login failed")
return
}
lockout.clear(body.Email)
if err := s.beginAuthenticatedSession(r.Context(), res.User.ID, res.CompanyID); err != nil {
Error(w, http.StatusInternalServerError, "session start failed")
return
}
JSON(w, http.StatusOK, res)
}
// sessionUserEmail returns the signed-in user's email when a session cookie is present.
func (s *Server) sessionUserEmail(ctx context.Context) (string, bool) {
uidStr := s.Sessions.GetString(ctx, auth.SessionUserIDKey)
if uidStr == "" {
return "", false
}
uid, err := uuid.Parse(uidStr)
if err != nil {
return "", false
}
user, err := s.Auth.GetUser(ctx, uid)
if err != nil {
return "", false
}
email := strings.TrimSpace(user.Email)
if email == "" {
return "", false
}
return email, true
}
func writeEmailMismatch(w http.ResponseWriter, sessionEmail, inviteEmail string) {
JSON(w, http.StatusConflict, map[string]string{
"error": "email_mismatch",
"code": "email_mismatch",
"message": PublicMessage(w, "You're signed in as a different email than this invite. Sign out to continue with the invited account, or ask an admin to re-issue the invite to your signed-in email."),
"session_email": sessionEmail,
"invite_email": inviteEmail,
})
}
func (s *Server) beginAuthenticatedSession(ctx context.Context, userID, companyID uuid.UUID) error {
if err := s.Sessions.RenewToken(ctx); err != nil {
return err
}
s.Sessions.Put(ctx, auth.SessionUserIDKey, userID.String())
s.putSessionVersion(ctx, userID)
// Fresh login/register clears any prior impersonation chain.
s.Sessions.Remove(ctx, auth.SessionImpersonatorIDKey)
if companyID == uuid.Nil {
s.Sessions.Put(ctx, auth.SessionCompanyIDKey, "")
return nil
}
s.Sessions.Put(ctx, auth.SessionCompanyIDKey, companyID.String())
return nil
}
// beginImpersonatedSession swaps the signed-in user while preserving the original actor.
func (s *Server) beginImpersonatedSession(ctx context.Context, targetUserID, companyID, actorID uuid.UUID) error {
if err := s.Sessions.RenewToken(ctx); err != nil {
return err
}
s.Sessions.Put(ctx, auth.SessionUserIDKey, targetUserID.String())
s.putSessionVersion(ctx, targetUserID)
// Keep the original impersonator across chained switches.
if existing := strings.TrimSpace(s.Sessions.GetString(ctx, auth.SessionImpersonatorIDKey)); existing == "" {
s.Sessions.Put(ctx, auth.SessionImpersonatorIDKey, actorID.String())
}
if companyID == uuid.Nil {
s.Sessions.Put(ctx, auth.SessionCompanyIDKey, "")
return nil
}
s.Sessions.Put(ctx, auth.SessionCompanyIDKey, companyID.String())
return nil
}
// putSessionVersion stamps users.session_version into the cookie session (0 when DB unavailable).
func (s *Server) putSessionVersion(ctx context.Context, userID uuid.UUID) {
version := 0
if s != nil && s.Auth != nil && s.Auth.Pool != nil {
if st, err := s.Auth.UserSessionState(ctx, userID); err == nil {
version = st.Version
}
}
s.Sessions.Put(ctx, auth.SessionVersionKey, version)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if err := s.Sessions.Destroy(r.Context()); err != nil {
Error(w, http.StatusInternalServerError, "logout failed")
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleInvitePreview(w http.ResponseWriter, r *http.Request) {
var body struct {
Token string `json:"token"`
Mode string `json:"mode"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
mode := strings.TrimSpace(strings.ToLower(body.Mode))
if mode == "" {
mode = "invite"
}
var inviteEmail string
switch mode {
case "set-password":
uid, err := auth.ParseSetPasswordToken(s.Config.TokenSigningSecret, body.Token)
if err != nil {
Error(w, http.StatusBadRequest, "invalid or expired token")
return
}
user, err := s.Auth.GetUser(r.Context(), uid)
if err != nil {
Error(w, http.StatusBadRequest, "invalid or expired token")
return
}
inviteEmail = user.Email
default:
mode = "invite"
email, err := s.Auth.ResolveInviteEmail(r.Context(), body.Token)
if errors.Is(err, auth.ErrInviteInvalid) {
Error(w, http.StatusBadRequest, "invite invalid or expired")
return
}
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "invite preview failed", err, auth.ClientError)
return
}
inviteEmail = email
}
out := map[string]any{
"mode": mode,
"invite_email": inviteEmail,
"valid": true,
"mismatch": false,
}
if sessionEmail, ok := s.sessionUserEmail(r.Context()); ok {
out["session_email"] = sessionEmail
if !auth.EmailsEqual(sessionEmail, inviteEmail) {
out["mismatch"] = true
}
}
JSON(w, http.StatusOK, out)
}
func (s *Server) handleAcceptInvite(w http.ResponseWriter, r *http.Request) {
var body struct {
Token string `json:"token"`
Password string `json:"password"`
Name string `json:"name"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if sessionEmail, ok := s.sessionUserEmail(r.Context()); ok {
inviteEmail, err := s.Auth.ResolveInviteEmail(r.Context(), body.Token)
if errors.Is(err, auth.ErrInviteInvalid) {
Error(w, http.StatusBadRequest, "invite invalid or expired")
return
}
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "invite accept failed", err, auth.ClientError)
return
}
if !auth.EmailsEqual(sessionEmail, inviteEmail) {
writeEmailMismatch(w, sessionEmail, inviteEmail)
return
}
}
res, err := s.Auth.AcceptInvite(r.Context(), body.Token, body.Password, body.Name)
if errors.Is(err, auth.ErrInviteInvalid) {
Error(w, http.StatusBadRequest, "invite invalid or expired")
return
}
if errors.Is(err, auth.ErrInvalidCredentials) {
FieldError(w, http.StatusUnauthorized, "invalid credentials", "invalid_credentials", map[string]string{
"password": "invalid credentials",
})
return
}
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "invite accept failed", err, auth.ClientError)
return
}
if err := s.beginAuthenticatedSession(r.Context(), res.User.ID, res.CompanyID); err != nil {
Error(w, http.StatusInternalServerError, "session start failed")
return
}
JSON(w, http.StatusOK, res)
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
uid, _ := UserIDFromContext(r.Context())
user, err := s.Auth.GetUser(r.Context(), uid)
if err != nil {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
companies, err := s.Auth.ListUserCompanies(r.Context(), uid)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to load companies")
return
}
cidStr := s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey)
out := map[string]any{
"user": user,
"companies": companies,
"active_company_id": cidStr,
}
if access, err := s.Auth.GetStaffAccess(r.Context(), uid); err == nil && (access.FullAdmin || access.SupportDesk) {
out["staff_access"] = access
out["staff_capabilities"] = auth.StaffCapabilities(access.Role)
}
if cid, err := uuid.Parse(cidStr); err == nil {
for _, c := range companies {
if c.ID == cid {
out["company"] = c
break
}
}
if credits, err := s.Billing.CreditsOverview(r.Context(), cid, s.Config.LowCreditsThreshold); err == nil {
out["credits"] = credits
}
if m, err := s.Auth.EnsureMembership(r.Context(), uid, cid); err == nil {
out["membership"] = map[string]string{"role": m.Role, "status": m.Status}
}
}
impersonating := false
if impStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionImpersonatorIDKey)); impStr != "" {
if impID, err := uuid.Parse(impStr); err == nil && impID != uuid.Nil {
impersonating = true
out["impersonating"] = true
if impUser, err := s.Auth.GetUser(r.Context(), impID); err == nil {
out["impersonator"] = map[string]any{
"id": impUser.ID,
"email": impUser.Email,
"name": impUser.Name,
}
} else {
out["impersonator"] = map[string]any{"id": impID}
}
}
}
if !s.Config.IsProduction() {
canSwitch := impersonating
if !canSwitch {
access, err := s.checkStaffAccess(r.Context(), uid)
if err == nil && access.FullAdmin {
canSwitch = true
} else if isLocalDemoEmail(user.Email) {
canSwitch = true
}
}
out["dev_user_switch"] = canSwitch
}
JSON(w, http.StatusOK, out)
}
func (s *Server) handleSetPassword(w http.ResponseWriter, r *http.Request) {
uid, _ := UserIDFromContext(r.Context())
var body struct {
Password string `json:"password"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if err := s.Auth.SetPassword(r.Context(), uid, body.Password); err != nil {
if errors.Is(err, auth.ErrPasswordAlreadySet) {
Error(w, http.StatusBadRequest, "password already set")
return
}
ClientOrLog(w, http.StatusBadRequest, "could not set password", err, auth.ClientError)
return
}
s.putSessionVersion(r.Context(), uid)
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
uid, _ := UserIDFromContext(r.Context())
var body struct {
CurrentPassword string `json:"current_password"`
Password string `json:"password"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if err := s.Auth.ChangePassword(r.Context(), uid, body.CurrentPassword, body.Password); err != nil {
if errors.Is(err, auth.ErrMustSetPassword) {
Error(w, http.StatusBadRequest, "set password first")
return
}
if errors.Is(err, auth.ErrInvalidCredentials) {
Error(w, http.StatusBadRequest, "current password is incorrect")
return
}
ClientOrLog(w, http.StatusBadRequest, "could not change password", err, auth.ClientError)
return
}
s.putSessionVersion(r.Context(), uid)
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleSelectCompany(w http.ResponseWriter, r *http.Request) {
uid, _ := UserIDFromContext(r.Context())
var body struct {
CompanyID string `json:"company_id"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
cid, err := uuid.Parse(body.CompanyID)
if err != nil {
Error(w, http.StatusBadRequest, "invalid company_id")
return
}
if _, err := s.Auth.EnsureMembership(r.Context(), uid, cid); err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
s.Sessions.Put(r.Context(), auth.SessionCompanyIDKey, cid.String())
JSON(w, http.StatusOK, map[string]string{"company_id": cid.String()})
}
@@ -0,0 +1,272 @@
package httpapi
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// TestAuthSessionCoreEndpoints exercises login/me/select-company/company/api-keys/logout
// with semi-real fixtures against a live DATABASE_URL (skips when unset).
func TestAuthSessionCoreEndpoints(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx := t.Context()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("postgres: %v", err)
}
t.Cleanup(pg.Close)
companyID := uuid.New()
userID := uuid.New()
prefix := companyID.String()[:8]
email := fmt.Sprintf("auth-smoke-%s@example.test", prefix)
password := "AuthSmoke123!"
hash, err := auth.HashPassword(password)
if err != nil {
t.Fatalf("hash password: %v", err)
}
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name, language) VALUES ($1, $2, 'en')`,
companyID, "Auth Smoke Co "+prefix)
if err != nil {
t.Fatalf("seed company: %v", err)
}
_, err = pg.Exec(ctx, `
INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active)
VALUES ($1, $2, $3, $4, false, false, true)`,
userID, email, "Auth Smoke", hash)
if err != nil {
t.Fatalf("seed user: %v", err)
}
_, err = pg.Exec(ctx, `
INSERT INTO memberships (company_id, user_id, role, status)
VALUES ($1, $2, 'admin', 'active')`, companyID, userID)
if err != nil {
t.Fatalf("seed membership: %v", err)
}
_, err = pg.Exec(ctx, `INSERT INTO credit_balances (company_id) VALUES ($1) ON CONFLICT DO NOTHING`, companyID)
if err != nil {
t.Fatalf("seed credits: %v", err)
}
// Free defaults deny settings.api_keys / capability.api_access; Starter+ matches prod gate.
billingSvc := &billing.Service{Pool: pg}
if err := billingSvc.EnsureDefaultPlans(ctx); err != nil {
t.Fatalf("ensure plans: %v", err)
}
starterID, err := billingSvc.PlanIDByName(ctx, "Starter")
if err != nil {
t.Fatalf("starter plan: %v", err)
}
assigned, err := billingSvc.AssignPlanIfMissing(ctx, companyID, starterID)
if err != nil {
t.Fatalf("assign starter: %v", err)
}
if !assigned {
t.Fatal("expected Starter plan assignment for api-key entitlement")
}
t.Cleanup(func() {
cleanupCtx := t.Context()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM api_keys WHERE company_id = $1 OR user_id = $2`, companyID, userID)
_, _ = pg.Exec(cleanupCtx, `DELETE FROM company_plans WHERE company_id = $1`, companyID)
_, _ = pg.Exec(cleanupCtx, `DELETE FROM memberships WHERE company_id = $1 OR user_id = $2`, companyID, userID)
_, _ = pg.Exec(cleanupCtx, `DELETE FROM credit_balances WHERE company_id = $1`, companyID)
_, _ = pg.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, userID)
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
})
sessions := auth.NewSessionManager(pg, "descrybe_session", false, 24)
s := &Server{
Config: config.Config{
CSRFCookieName: "descrybe_csrf",
WebOrigin: "http://localhost:5174",
SessionSecure: false,
LowCreditsThreshold: 100,
TokenSigningSecret: "test-token-signing-secret-32chars!!",
},
Pool: pg,
Sessions: sessions,
Auth: &auth.Service{Pool: pg},
Billing: &billing.Service{Pool: pg},
Catalog: &catalog.Service{Pool: pg},
}
h := s.Router()
jar := map[string]string{}
collectCookies := func(rec *httptest.ResponseRecorder) {
for _, c := range rec.Result().Cookies() {
if c.MaxAge < 0 || (c.Expires.Before(time.Now()) && !c.Expires.IsZero()) {
delete(jar, c.Name)
continue
}
if c.Value != "" {
jar[c.Name] = c.Value
}
}
}
applyCookies := func(req *http.Request) {
for name, value := range jar {
req.AddCookie(&http.Cookie{Name: name, Value: value})
}
}
do := func(method, path, body string, withCSRF bool) *httptest.ResponseRecorder {
var req *http.Request
if body == "" {
req = httptest.NewRequest(method, path, nil)
} else {
req = httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
}
req.RemoteAddr = "127.0.0.1:34567"
applyCookies(req)
if withCSRF {
csrf := jar["descrybe_csrf"]
if csrf == "" {
t.Fatal("missing CSRF cookie before mutating request")
}
req.Header.Set("X-CSRF-Token", csrf)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
collectCookies(rec)
return rec
}
decode := func(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("json status=%d body=%s err=%v", rec.Code, rec.Body.String(), err)
}
return out
}
// Seed CSRF via unauthenticated /me (401 expected).
rec := do(http.MethodGet, "/api/auth/me", "", false)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unauth me status=%d body=%s", rec.Code, rec.Body.String())
}
if jar["descrybe_csrf"] == "" {
t.Fatal("expected descrybe_csrf cookie")
}
// Login without CSRF → 403.
rec = do(http.MethodPost, "/api/auth/login",
fmt.Sprintf(`{"email":%q,"password":%q}`, email, password), false)
if rec.Code != http.StatusForbidden {
t.Fatalf("login without csrf status=%d body=%s", rec.Code, rec.Body.String())
}
// Bad password → 401.
rec = do(http.MethodPost, "/api/auth/login",
fmt.Sprintf(`{"email":%q,"password":"WrongPass999!"}`, email), true)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("bad password status=%d body=%s", rec.Code, rec.Body.String())
}
// Successful login.
rec = do(http.MethodPost, "/api/auth/login",
fmt.Sprintf(`{"email":%q,"password":%q}`, email, password), true)
if rec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
}
login := decode(t, rec)
if fmt.Sprint(login["company_id"]) != companyID.String() {
t.Fatalf("login company_id=%v want %s", login["company_id"], companyID)
}
userObj, _ := login["user"].(map[string]any)
if fmt.Sprint(userObj["email"]) != email {
t.Fatalf("login email=%v", userObj["email"])
}
if jar["descrybe_session"] == "" {
t.Fatal("expected session cookie after login")
}
// Me.
rec = do(http.MethodGet, "/api/auth/me", "", false)
if rec.Code != http.StatusOK {
t.Fatalf("me status=%d body=%s", rec.Code, rec.Body.String())
}
me := decode(t, rec)
if fmt.Sprint(me["active_company_id"]) != companyID.String() {
t.Fatalf("active_company_id=%v", me["active_company_id"])
}
if _, ok := me["credits"]; !ok {
t.Fatalf("me missing credits: %v", me)
}
// Select company (same id).
rec = do(http.MethodPost, "/api/auth/select-company",
fmt.Sprintf(`{"company_id":%q}`, companyID.String()), true)
if rec.Code != http.StatusOK {
t.Fatalf("select-company status=%d body=%s", rec.Code, rec.Body.String())
}
// Core tenant routes.
rec = do(http.MethodGet, "/api/company", "", false)
if rec.Code != http.StatusOK {
t.Fatalf("company status=%d body=%s", rec.Code, rec.Body.String())
}
co := decode(t, rec)
if !strings.Contains(fmt.Sprint(co["name"]), "Auth Smoke Co") {
t.Fatalf("company name=%v", co["name"])
}
rec = do(http.MethodGet, "/api/billing/credits", "", false)
if rec.Code != http.StatusOK {
t.Fatalf("billing credits status=%d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodGet, "/api/api-keys", "", false)
if rec.Code != http.StatusOK {
t.Fatalf("list api-keys status=%d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodPost, "/api/api-keys", `{"name":"auth-smoke-key"}`, true)
if rec.Code != http.StatusCreated {
t.Fatalf("create api-key status=%d body=%s", rec.Code, rec.Body.String())
}
created := decode(t, rec)
rawKey := fmt.Sprint(created["key"])
keyID := fmt.Sprint(created["id"])
if !strings.HasPrefix(rawKey, "dk_") || keyID == "" {
t.Fatalf("create api-key payload=%v", created)
}
// Public v1 with the new key (CSRF skipped).
v1 := httptest.NewRecorder()
v1Req := httptest.NewRequest(http.MethodGet, "/api/v1/products?limit=1", nil)
v1Req.Header.Set("Authorization", "Bearer "+rawKey)
h.ServeHTTP(v1, v1Req)
if v1.Code != http.StatusOK {
t.Fatalf("v1 products status=%d body=%s", v1.Code, v1.Body.String())
}
rec = do(http.MethodDelete, "/api/api-keys/"+keyID, "", true)
if rec.Code != http.StatusOK {
t.Fatalf("revoke api-key status=%d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodPost, "/api/auth/logout", `{}`, true)
if rec.Code != http.StatusOK {
t.Fatalf("logout status=%d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodGet, "/api/auth/me", "", false)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("me after logout status=%d body=%s", rec.Code, rec.Body.String())
}
}
@@ -0,0 +1,157 @@
package httpapi
import (
"errors"
"log"
"net/http"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/google/uuid"
)
func (s *Server) handleCreditsOverview(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
overview, err := s.Billing.CreditsOverview(r.Context(), cid, s.Config.LowCreditsThreshold)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to load credits")
return
}
JSON(w, http.StatusOK, overview)
}
func (s *Server) handleBillingUsage(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
usage, err := s.Billing.UsageSummary(r.Context(), cid, r.URL.Query().Get("range"))
if err != nil {
Error(w, http.StatusInternalServerError, "failed to load usage")
return
}
JSON(w, http.StatusOK, usage)
}
// handleListPlans returns every plan (including client deals) for platform admin.
func (s *Server) handleListPlans(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
_ = s.Billing.EnsureDefaultPlans(r.Context())
plans, err := s.Billing.ListPlans(r.Context())
if err != nil {
Error(w, http.StatusInternalServerError, "failed to list plans")
return
}
// Permission matrices are admin-sensitive; never let shared caches retain them.
w.Header().Set("Cache-Control", "private, no-store")
JSON(w, http.StatusOK, map[string]any{"plans": plans})
}
// handleListPublicPlans returns Free/Starter/Plus/Growth/Business/Scale/Enterprise only.
// Hides client-specific deals (A1, Merkur trial, legacy ladders) from company UI.
func (s *Server) handleListPublicPlans(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil || s.Billing.Pool == nil {
// Empty billing must not 500 on the public pricing surface.
JSON(w, http.StatusOK, map[string]any{
"plans": []any{},
"credits_per_ai_product": billing.CreditsPerAIProduct,
"assumed_content_langs": billing.AssumedPrimaryContentLanguages,
})
return
}
_ = s.Billing.EnsureDefaultPlans(r.Context())
plans, err := s.Billing.ListPublicPlans(r.Context())
if err != nil {
Error(w, http.StatusInternalServerError, "failed to list plans")
return
}
JSON(w, http.StatusOK, map[string]any{
"plans": plans,
"credits_per_ai_product": billing.CreditsPerAIProduct,
"assumed_content_langs": billing.AssumedPrimaryContentLanguages,
})
}
// handleListCreditPacks returns one-time AI credit top-up packages for Checkout.
func (s *Server) handleListCreditPacks(w http.ResponseWriter, r *http.Request) {
JSON(w, http.StatusOK, map[string]any{"packs": billing.DefaultCreditPacks()})
}
func (s *Server) handleUpsertPlan(w http.ResponseWriter, r *http.Request) {
var body billing.Plan
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
plan, err := s.Billing.UpsertPlan(r.Context(), body)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not save plan", err, billing.ClientError)
return
}
JSON(w, http.StatusOK, plan)
}
func (s *Server) handleAssignPlan(w http.ResponseWriter, r *http.Request) {
var body struct {
CompanyID string `json:"company_id"`
PlanID int64 `json:"plan_id"`
IsTrial bool `json:"is_trial"`
TrialCredits int `json:"trial_credits"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
cid, err := uuid.Parse(body.CompanyID)
if err != nil {
Error(w, http.StatusBadRequest, "invalid company_id")
return
}
if body.PlanID <= 0 {
Error(w, http.StatusBadRequest, "invalid plan_id")
return
}
if err := s.Billing.AssignPlan(r.Context(), cid, body.PlanID, body.IsTrial, body.TrialCredits); err != nil {
if errors.Is(err, billing.ErrPlanNotFound) {
Error(w, http.StatusNotFound, billing.ErrPlanNotFound.Error())
return
}
ClientOrLog(w, http.StatusBadRequest, "could not assign plan", err, billing.ClientError)
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleAddCredits(w http.ResponseWriter, r *http.Request) {
var body struct {
CompanyID string `json:"company_id"`
Amount int `json:"amount"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
cid, err := uuid.Parse(body.CompanyID)
if err != nil {
Error(w, http.StatusBadRequest, "invalid company_id")
return
}
if err := s.Billing.AddCredits(r.Context(), cid, body.Amount); err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not add credits", err, billing.ClientError)
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleRunBillingCycles(w http.ResponseWriter, r *http.Request) {
res, err := s.Billing.RunDueBillingCycles(r.Context())
if err != nil && res.Processed == 0 && res.Failed == 0 {
LogAndError(w, http.StatusInternalServerError, "billing cycle run failed", err)
return
}
out := map[string]any{"processed": res.Processed, "failed": res.Failed}
if err != nil {
log.Printf("httpapi: billing cycle run completed with errors: %v", err)
out["error"] = "billing cycle run completed with errors"
}
JSON(w, http.StatusOK, out)
}
+107
View File
@@ -0,0 +1,107 @@
package httpapi
import (
"net/http"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
)
type brandPutBody struct {
VoiceTone *string `json:"voice_tone"`
Dos []string `json:"dos"`
Donts []string `json:"donts"`
PrimaryColor *string `json:"primary_color"`
SecondaryColor *string `json:"secondary_color"`
LogoURL *string `json:"logo_url"`
PreferredTerms []string `json:"preferred_terms"`
// Optional nested colors alias
Colors *struct {
Primary *string `json:"primary"`
Secondary *string `json:"secondary"`
} `json:"colors"`
}
func (s *Server) brandResponse(w http.ResponseWriter, r *http.Request, brand company.BrandKit) {
cid, _ := CompanyIDFromContext(r.Context())
aiApply := false
if s.Billing != nil {
aiApply = s.Billing.AIBrandApplyAllowed(r.Context(), cid)
}
JSON(w, http.StatusOK, map[string]any{
"brand": brand,
"ai_apply_allowed": aiApply,
"tips": brand.FormulaTips(),
})
}
func (s *Server) handleGetBrand(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
brand, err := company.LoadBrand(r.Context(), s.Pool, cid)
if err != nil {
Error(w, http.StatusInternalServerError, "load brand failed")
return
}
s.brandResponse(w, r, brand)
}
func (s *Server) handlePutBrand(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
return
}
var body brandPutBody
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
current, err := company.LoadBrand(r.Context(), s.Pool, cid)
if err != nil {
Error(w, http.StatusInternalServerError, "load brand failed")
return
}
if body.VoiceTone != nil {
current.VoiceTone = *body.VoiceTone
}
if body.Dos != nil {
current.Dos = body.Dos
}
if body.Donts != nil {
current.Donts = body.Donts
}
if body.PrimaryColor != nil {
current.PrimaryColor = *body.PrimaryColor
}
if body.SecondaryColor != nil {
current.SecondaryColor = *body.SecondaryColor
}
if body.LogoURL != nil {
current.LogoURL = *body.LogoURL
}
if body.PreferredTerms != nil {
current.PreferredTerms = body.PreferredTerms
}
if body.Colors != nil {
if body.Colors.Primary != nil {
current.PrimaryColor = *body.Colors.Primary
}
if body.Colors.Secondary != nil {
current.SecondaryColor = *body.Colors.Secondary
}
}
saved, err := company.UpsertBrand(r.Context(), s.Pool, cid, current)
if err != nil {
if isBrandLogoURLError(err) {
Error(w, http.StatusBadRequest, "invalid logo_url")
return
}
Error(w, http.StatusInternalServerError, "save brand failed")
return
}
s.brandResponse(w, r, saved)
}
@@ -0,0 +1,129 @@
package httpapi
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
const brandLogoMaxUpload = 3 << 20 // parse budget slightly above 2 MiB file cap
func (s *Server) handleUploadBrandLogo(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
return
}
if err := r.ParseMultipartForm(brandLogoMaxUpload); err != nil {
Error(w, http.StatusBadRequest, "invalid multipart form")
return
}
file, header, err := r.FormFile("file")
if err != nil {
file, header, err = r.FormFile("logo")
}
if err != nil {
Error(w, http.StatusBadRequest, "file field required")
return
}
defer file.Close()
logoURL, _, _, _, err := company.SaveBrandLogo(
s.Config.UploadDir,
cid,
header.Filename,
header.Header.Get("Content-Type"),
file,
)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not upload logo", err, company.ClientError)
return
}
current, err := company.LoadBrand(r.Context(), s.Pool, cid)
if err != nil {
Error(w, http.StatusInternalServerError, "load brand failed")
return
}
current.LogoURL = logoURL
saved, err := company.UpsertBrand(r.Context(), s.Pool, cid, current)
if err != nil {
Error(w, http.StatusInternalServerError, "save brand failed")
return
}
s.brandResponse(w, r, saved)
}
func (s *Server) handleGetBrandLogoFile(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
name := chi.URLParam(r, "filename")
s.serveBrandLogo(w, r, cid, name)
}
func (s *Server) handlePublicBrandLogo(w http.ResponseWriter, r *http.Request) {
companyRaw := chi.URLParam(r, "companyID")
cid, err := uuid.Parse(companyRaw)
if err != nil {
Error(w, http.StatusBadRequest, "invalid company id")
return
}
name := chi.URLParam(r, "filename")
expRaw := strings.TrimSpace(r.URL.Query().Get("exp"))
sig := strings.TrimSpace(r.URL.Query().Get("sig"))
exp, err := strconv.ParseInt(expRaw, 10, 64)
if err != nil {
Error(w, http.StatusForbidden, "invalid or expired signature")
return
}
secret := strings.TrimSpace(s.Config.TokenSigningSecret)
if secret == "" {
Error(w, http.StatusServiceUnavailable, "signed logos unavailable")
return
}
if err := company.VerifyPublicBrandLogoSig(secret, cid, name, exp, sig); err != nil {
Error(w, http.StatusForbidden, "invalid or expired signature")
return
}
s.serveBrandLogo(w, r, cid, name)
}
func (s *Server) serveBrandLogo(w http.ResponseWriter, r *http.Request, companyID uuid.UUID, name string) {
f, contentType, err := company.OpenBrandLogo(s.Config.UploadDir, companyID, name)
if err != nil {
switch {
case errors.Is(err, company.ErrLogoInvalidName), errors.Is(err, company.ErrLogoForbidden):
Error(w, http.StatusBadRequest, "invalid logo path")
case errors.Is(err, company.ErrLogoNotFound):
Error(w, http.StatusNotFound, "logo not found")
default:
Error(w, http.StatusInternalServerError, "could not open logo")
}
return
}
defer f.Close()
st, err := f.Stat()
if err != nil {
Error(w, http.StatusInternalServerError, "could not stat logo")
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "private, max-age=3600")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeContent(w, r, name, st.ModTime(), f)
}
func isBrandLogoURLError(err error) bool {
return errors.Is(err, security.ErrInvalidURL) ||
errors.Is(err, security.ErrBlockedURL) ||
errors.Is(err, security.ErrBlockedHost) ||
errors.Is(err, company.ErrLogoInvalidName)
}
@@ -0,0 +1,298 @@
package httpapi
import (
"errors"
"io"
"net/http"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/campaigns"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func (s *Server) handleListCampaignTemplates(w http.ResponseWriter, r *http.Request) {
if !s.requireFeatures(w, r, "marketing.campaigns") {
return
}
JSON(w, http.StatusOK, map[string]any{"templates": campaigns.ListTemplates()})
}
func (s *Server) handleListCampaigns(w http.ResponseWriter, r *http.Request) {
limit, offset := ParseLimitOffset(r)
if !s.requireFeatures(w, r, "marketing.campaigns") {
return
}
if s.Campaigns == nil {
JSON(w, http.StatusOK, map[string]any{"campaigns": []any{}, "total": 0, "limit": limit, "offset": offset})
return
}
cid, _ := CompanyIDFromContext(r.Context())
items, total, err := s.Campaigns.List(r.Context(), cid, limit, offset)
if err != nil {
// First-run / missing migration: empty list so the UI empty-state works.
JSON(w, http.StatusOK, map[string]any{"campaigns": []any{}, "total": 0, "limit": limit, "offset": offset})
return
}
if items == nil {
items = []campaigns.Campaign{}
}
JSON(w, http.StatusOK, map[string]any{"campaigns": items, "total": total, "limit": limit, "offset": offset})
}
func (s *Server) handleCreateCampaign(w http.ResponseWriter, r *http.Request) {
if s.Campaigns == nil {
Error(w, http.StatusServiceUnavailable, "campaigns unavailable")
return
}
if !s.requireFeatures(w, r, "marketing.campaigns", "marketing.campaigns.create") {
return
}
cid, _ := CompanyIDFromContext(r.Context())
uid, _ := UserIDFromContext(r.Context())
var body campaigns.CreateInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Campaigns.Create(r.Context(), cid, &uid, body)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not create campaign", err, campaigns.ClientError)
return
}
JSON(w, http.StatusCreated, item)
}
func (s *Server) handleGetCampaign(w http.ResponseWriter, r *http.Request) {
if s.Campaigns == nil {
Error(w, http.StatusServiceUnavailable, "campaigns unavailable")
return
}
if !s.requireFeatures(w, r, "marketing.campaigns") {
return
}
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Campaigns.Get(r.Context(), cid, id)
if errors.Is(err, campaigns.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "get failed")
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleUpdateCampaign(w http.ResponseWriter, r *http.Request) {
if !s.requireFeatures(w, r, "marketing.campaigns", "marketing.campaigns.create") {
return
}
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body campaigns.UpdateInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Campaigns.Update(r.Context(), cid, id, body)
if errors.Is(err, campaigns.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update campaign", err, campaigns.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleDeleteCampaign(w http.ResponseWriter, r *http.Request) {
if !s.requireFeatures(w, r, "marketing.campaigns", "marketing.campaigns.create") {
return
}
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.Campaigns.Delete(r.Context(), cid, id); errors.Is(err, campaigns.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
} else if err != nil {
Error(w, http.StatusInternalServerError, "delete failed")
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleGenerateCampaign(w http.ResponseWriter, r *http.Request) {
if !s.requireFeatures(w, r, "marketing.campaigns") {
return
}
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body campaigns.GenerateInput
err = DecodeJSON(r, &body)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, errJSONBodyTooLarge) {
// Allow empty body (defaults to template mode).
if r.ContentLength > 0 {
Error(w, http.StatusBadRequest, "invalid json")
return
}
}
item, err := s.Campaigns.Generate(r.Context(), cid, id, body)
if errors.Is(err, campaigns.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if writePlanGate(w, err) {
return
}
if errors.Is(err, campaigns.ErrAIRequiresUpgrade) || errors.Is(err, billing.ErrAIRequiresUpgrade) {
JSON(w, http.StatusPaymentRequired, map[string]any{
"error": err.Error(),
"code": "ai_requires_upgrade",
"upgrade_url": "/pricing",
})
return
}
if errors.Is(err, campaigns.ErrInsufficientCredits) || errors.Is(err, billing.ErrInsufficientCredits) {
JSON(w, http.StatusPaymentRequired, map[string]any{
"error": err.Error(),
"code": "insufficient_credits",
"upgrade_url": "/pricing",
})
return
}
if errors.Is(err, campaigns.ErrRateLimited) {
w.Header().Set("Retry-After", "60")
Error(w, http.StatusTooManyRequests, err.Error())
return
}
if err != nil {
if writeCampaignClientErr(w, err) {
return
}
LogAndError(w, http.StatusBadRequest, "campaign generate failed", err)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleSendTestCampaign(w http.ResponseWriter, r *http.Request) {
if !s.requireFeatures(w, r, "marketing.campaigns") {
return
}
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body campaigns.SendTestInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Campaigns.SendTest(r.Context(), cid, id, body)
if writeCampaignSendErr(w, err) {
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleScheduleCampaign(w http.ResponseWriter, r *http.Request) {
if !s.requireFeatures(w, r, "marketing.campaigns") {
return
}
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body campaigns.ScheduleInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Campaigns.Schedule(r.Context(), cid, id, body)
if writeCampaignSendErr(w, err) {
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleSendCampaign(w http.ResponseWriter, r *http.Request) {
if !s.requireFeatures(w, r, "marketing.campaigns") {
return
}
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body campaigns.SendInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Campaigns.Send(r.Context(), cid, id, body)
if writeCampaignSendErr(w, err) {
return
}
JSON(w, http.StatusOK, item)
}
// writeCampaignSendErr writes an error response and returns true when err != nil.
func writeCampaignSendErr(w http.ResponseWriter, err error) bool {
if err == nil {
return false
}
if writePlanGate(w, err) {
return true
}
switch {
case errors.Is(err, campaigns.ErrNotFound):
Error(w, http.StatusNotFound, "not found")
case errors.Is(err, campaigns.ErrProviderNotFound), errors.Is(err, campaigns.ErrProviderUnverified):
JSON(w, http.StatusPreconditionFailed, map[string]any{
"error": err.Error(),
"code": "email_not_verified",
})
case errors.Is(err, campaigns.ErrRateLimited):
w.Header().Set("Retry-After", "60")
Error(w, http.StatusTooManyRequests, err.Error())
default:
if writeCampaignClientErr(w, err) {
return true
}
LogAndError(w, http.StatusBadRequest, "campaign send failed", err)
}
return true
}
// writeCampaignClientErr maps known campaign validation sentinels to 400.
func writeCampaignClientErr(w http.ResponseWriter, err error) bool {
if msg, ok := campaigns.ClientError(err); ok {
Error(w, http.StatusBadRequest, msg)
return true
}
return false
}
@@ -0,0 +1,462 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func (s *Server) handleListCategories(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
max := maxPageLimit
if r.URL.Query().Get("tree") == "1" {
max = maxTreePageLimit
}
limit, offset := ParseLimitOffsetMax(r, max)
f := catalog.ListFilter{
Query: QuerySearch(r),
Limit: limit,
Offset: offset,
}
items, total, err := s.Catalog.ListCategories(r.Context(), cid, f)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{"categories": items, "total": total, "limit": limit, "offset": offset})
}
func (s *Server) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var body struct {
Name string `json:"name"`
UniqueID string `json:"unique_id"`
ParentUniqueID *string `json:"parent_unique_id"`
Description *string `json:"description"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Catalog.CreateCategory(r.Context(), cid, body.Name, body.UniqueID, body.ParentUniqueID, body.Description)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not create category", err, catalog.ClientError)
return
}
JSON(w, http.StatusCreated, item)
}
func (s *Server) handleGetCategory(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Catalog.GetCategory(r.Context(), cid, id)
if err != nil {
Error(w, http.StatusNotFound, "not found")
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleUpdateCategory(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body map[string]any
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Catalog.UpdateCategory(r.Context(), cid, id, body)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update category", err, catalog.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleDeleteCategory(w http.ResponseWriter, r *http.Request) {
if !requireCompanyAdmin(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.Catalog.DeleteCategory(r.Context(), cid, id); err != nil {
Error(w, http.StatusInternalServerError, "delete failed")
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleUpdateTitleFormula(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
TitleTemplate any `json:"title_template"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Catalog.UpdateTitleFormula(r.Context(), cid, id, body.TitleTemplate)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update title formula", err, catalog.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleUpdateDescriptionFormula(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
DescriptionTemplate any `json:"description_template"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Catalog.UpdateDescriptionFormula(r.Context(), cid, id, body.DescriptionTemplate)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update description formula", err, catalog.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleUpdateCategoryPrompt(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
Prompt string `json:"prompt"`
Language string `json:"language"`
Prompts map[string]string `json:"prompts"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
prompts := body.Prompts
if prompts == nil {
prompts = map[string]string{}
lang := strings.TrimSpace(body.Language)
if lang == "" {
lang = company.LoadLanguage(r.Context(), s.Pool, cid)
}
// Legacy single-prompt body: set/clear one language, preserve others.
existing, gerr := s.Catalog.GetCategory(r.Context(), cid, id)
if gerr == nil {
if m, ok := existing["prompts"].(company.LangPromptMap); ok {
for k, v := range m {
prompts[k] = v
}
} else if raw, ok := existing["prompts"].(map[string]string); ok {
for k, v := range raw {
prompts[k] = v
}
} else if raw, ok := existing["prompts"].(map[string]any); ok {
for k, v := range raw {
if s, ok := v.(string); ok {
prompts[k] = s
}
}
}
}
prompts[lang] = body.Prompt
}
item, err := s.Catalog.UpdateCategoryPrompt(r.Context(), cid, id, prompts)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update category prompt", err, catalog.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleListVariables(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
limit, offset := ParseLimitOffsetMax(r, maxTreePageLimit)
page, total, err := s.Catalog.ListVariables(r.Context(), cid, catalog.ListFilter{Limit: limit, Offset: offset})
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{"variables": page, "total": total, "limit": limit, "offset": offset})
}
func (s *Server) handleCreateVariable(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var body struct {
Name string `json:"name"`
Value string `json:"value"`
Label string `json:"label"`
Description *string `json:"description"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
value := body.Value
if value == "" && body.Label != "" {
value = body.Label
}
item, err := s.Catalog.CreateVariable(r.Context(), cid, body.Name, value, body.Description)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not create variable", err, catalog.ClientError)
return
}
JSON(w, http.StatusCreated, item)
}
func (s *Server) handleDeleteVariable(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.Catalog.DeleteVariable(r.Context(), cid, id); err != nil {
if errors.Is(err, catalog.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
ClientOrLog(w, http.StatusNotFound, "could not delete variable", err, catalog.ClientError)
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleListAttributes(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
limit, offset := ParseLimitOffset(r)
f := catalog.ListFilter{
Query: QuerySearch(r),
Limit: limit,
Offset: offset,
RootsOnly: r.URL.Query().Get("roots") == "1",
ParentKey: r.URL.Query().Get("parent_key"),
}
items, total, err := s.Catalog.ListAttributes(r.Context(), cid, f)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{"attributes": items, "total": total, "limit": limit, "offset": offset})
}
func (s *Server) handleCreateAttribute(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var body struct {
AttributeKey string `json:"attribute_key"`
Name string `json:"name"`
ValueType string `json:"value_type"`
Unit *string `json:"unit"`
Example *string `json:"example"`
ParentKey *string `json:"parent_key"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Catalog.CreateAttribute(r.Context(), cid, body.AttributeKey, body.Name, body.ValueType, body.Unit, body.Example, body.ParentKey)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not create attribute", err, catalog.ClientError)
return
}
JSON(w, http.StatusCreated, item)
}
func (s *Server) handleUpdateAttribute(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body map[string]any
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Catalog.UpdateAttribute(r.Context(), cid, id, body)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update attribute", err, catalog.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleDeleteAttribute(w http.ResponseWriter, r *http.Request) {
if !requireCompanyAdmin(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.Catalog.DeleteAttribute(r.Context(), cid, id); err != nil {
if errors.Is(err, catalog.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
Error(w, http.StatusInternalServerError, "delete failed")
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleListProducts(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
limit, offset := ParseLimitOffset(r)
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
afterID := strings.TrimSpace(firstNonEmpty(r.URL.Query().Get("after_id"), r.URL.Query().Get("afterId")))
f := catalog.ListFilter{
Query: QuerySearch(r),
Status: r.URL.Query().Get("status"),
Category: r.URL.Query().Get("category"),
FeedID: firstNonEmpty(r.URL.Query().Get("feed_id"), r.URL.Query().Get("feedId")),
Coverage: firstNonEmpty(r.URL.Query().Get("coverage"), r.URL.Query().Get("missing")),
Eprel: firstNonEmpty(r.URL.Query().Get("eprel"), r.URL.Query().Get("has_eprel")),
SyncChange: firstNonEmpty(r.URL.Query().Get("sync_change"), r.URL.Query().Get("syncChange"), r.URL.Query().Get("feed_change")),
SortBy: firstNonEmpty(r.URL.Query().Get("sort_by"), r.URL.Query().Get("sortBy")),
SortOrder: firstNonEmpty(r.URL.Query().Get("sort_order"), r.URL.Query().Get("sortOrder")),
Limit: limit,
Offset: offset,
Cursor: cursor,
AfterID: afterID,
}
if catalog.HasProductCursor(f) {
offset = 0
}
kind := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("kind")))
// UI and some clients send kind=unprocessed for the raw inventory tab.
if kind == "raw" || kind == "unprocessed" {
items, total, err := s.Catalog.ListRawProducts(r.Context(), cid, f)
if err != nil {
if msg, ok := catalog.ClientError(err); ok {
Error(w, http.StatusBadRequest, msg)
return
}
Error(w, http.StatusInternalServerError, "list failed")
return
}
resp := map[string]any{"products": items, "total": total, "kind": "raw", "limit": limit, "offset": offset}
if nextCursor, nextAfter := catalog.NextProductCursor(f, items, limit); nextCursor != "" || nextAfter != "" {
if nextCursor != "" {
resp["next_cursor"] = nextCursor
}
if nextAfter != "" {
resp["next_after_id"] = nextAfter
}
}
JSON(w, http.StatusOK, resp)
return
}
detailed := QueryDetailed(r)
var (
items []map[string]any
total int64
err error
)
if detailed {
items, total, err = s.Catalog.ListProcessedProductsDetailed(r.Context(), cid, f)
} else {
items, total, err = s.Catalog.ListProcessedProducts(r.Context(), cid, f)
}
if err != nil {
if msg, ok := catalog.ClientError(err); ok {
Error(w, http.StatusBadRequest, msg)
return
}
Error(w, http.StatusInternalServerError, "list failed")
return
}
if detailed {
attachProductQuality(items)
}
resp := map[string]any{"products": items, "total": total, "kind": "processed", "limit": limit, "offset": offset, "detailed": detailed}
if nextCursor, nextAfter := catalog.NextProductCursor(f, items, limit); nextCursor != "" || nextAfter != "" {
if nextCursor != "" {
resp["next_cursor"] = nextCursor
}
if nextAfter != "" {
resp["next_after_id"] = nextAfter
}
}
JSON(w, http.StatusOK, resp)
}
func (s *Server) handleGetProduct(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Catalog.GetProcessedProduct(r.Context(), cid, id)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
Error(w, http.StatusInternalServerError, "lookup failed")
return
}
item, err = s.Catalog.GetRawProduct(r.Context(), cid, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
Error(w, http.StatusNotFound, "not found")
return
}
Error(w, http.StatusInternalServerError, "lookup failed")
return
}
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleUpdateProduct(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body map[string]any
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Catalog.UpdateProcessedProduct(r.Context(), cid, id, body)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update product", err, catalog.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
@@ -0,0 +1,287 @@
package httpapi
import (
"net/http"
"os"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
const catalogMaxUpload = 6 << 20
func (s *Server) handleListCategoryAttributes(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
cat, err := s.Catalog.GetCategory(r.Context(), cid, id)
if err != nil {
Error(w, http.StatusNotFound, "not found")
return
}
uniqueID, _ := cat["unique_id"].(string)
items, err := s.Catalog.ListCategoryAttributes(r.Context(), cid, uniqueID)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not list category attributes", err, catalog.ClientError)
return
}
limit, offset := ParseLimitOffsetMax(r, maxTreePageLimit)
page, total := pageSlice(items, limit, offset)
JSON(w, http.StatusOK, map[string]any{
"category_attributes": page, "category_unique_id": uniqueID,
"total": total, "limit": limit, "offset": offset,
})
}
func (s *Server) handlePutCategoryAttributes(w http.ResponseWriter, r *http.Request) {
if !requireCompanyAdmin(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
cat, err := s.Catalog.GetCategory(r.Context(), cid, id)
if err != nil {
Error(w, http.StatusNotFound, "not found")
return
}
uniqueID, _ := cat["unique_id"].(string)
var body struct {
AttributeIDs []string `json:"attribute_ids"`
Required map[string]bool `json:"required"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
ids := make([]uuid.UUID, 0, len(body.AttributeIDs))
for _, raw := range body.AttributeIDs {
aid, err := uuid.Parse(raw)
if err != nil {
Error(w, http.StatusBadRequest, "invalid attribute_ids")
return
}
ids = append(ids, aid)
}
if body.Required == nil {
body.Required = map[string]bool{}
}
if err := s.Catalog.ReplaceCategoryAttributes(r.Context(), cid, uniqueID, ids, body.Required); err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update category attributes", err, catalog.ClientError)
return
}
items, err := s.Catalog.ListCategoryAttributes(r.Context(), cid, uniqueID)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{"category_attributes": items})
}
func (s *Server) handleLinkCategoryAttribute(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
cat, err := s.Catalog.GetCategory(r.Context(), cid, id)
if err != nil {
Error(w, http.StatusNotFound, "not found")
return
}
uniqueID, _ := cat["unique_id"].(string)
var body struct {
AttributeID string `json:"attribute_id"`
Required bool `json:"required"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
aid, err := uuid.Parse(body.AttributeID)
if err != nil {
Error(w, http.StatusBadRequest, "invalid attribute_id")
return
}
item, err := s.Catalog.LinkCategoryAttribute(r.Context(), cid, uniqueID, aid, body.Required)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not link attribute", err, catalog.ClientError)
return
}
JSON(w, http.StatusCreated, item)
}
func (s *Server) handleUnlinkCategoryAttribute(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
aid, err := uuid.Parse(chi.URLParam(r, "attributeID"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid attribute id")
return
}
cat, err := s.Catalog.GetCategory(r.Context(), cid, id)
if err != nil {
Error(w, http.StatusNotFound, "not found")
return
}
uniqueID, _ := cat["unique_id"].(string)
if err := s.Catalog.UnlinkCategoryAttribute(r.Context(), cid, uniqueID, aid); err != nil {
if msg, ok := catalog.ClientError(err); ok {
Error(w, http.StatusNotFound, msg)
return
}
LogAndError(w, http.StatusNotFound, "could not unlink attribute", err)
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleListFiles(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
limit, offset := ParseLimitOffsetMax(r, maxPageLimit)
items, total, err := s.Catalog.ListFiles(r.Context(), cid, catalog.ListFilter{Limit: limit, Offset: offset})
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{"files": items, "total": total, "limit": limit, "offset": offset})
}
func (s *Server) handleDeleteFile(w http.ResponseWriter, r *http.Request) {
if !requireCompanyAdmin(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.Catalog.DeleteFile(r.Context(), cid, id, s.Config.UploadDir); err != nil {
Error(w, http.StatusNotFound, "not found")
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
if !requireCompanyAdmin(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
uid, _ := UserIDFromContext(r.Context())
kind := importKindFromPath(r)
switch kind {
case "categories", "attributes", "products":
default:
Error(w, http.StatusBadRequest, "kind must be categories, attributes, or products")
return
}
if err := r.ParseMultipartForm(catalogMaxUpload); err != nil {
Error(w, http.StatusBadRequest, "invalid multipart form")
return
}
file, header, err := r.FormFile("file")
if err != nil {
Error(w, http.StatusBadRequest, "file field required")
return
}
defer file.Close()
meta, err := s.Catalog.SaveUpload(r.Context(), cid, uid, s.Config.UploadDir, header.Filename, header.Header.Get("Content-Type"), kind, file)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not save upload", err, catalog.ClientError)
return
}
fileIDStr, _ := meta["id"].(string)
fileID, _ := uuid.Parse(fileIDStr)
_, _ = s.Catalog.UpdateFileStatus(r.Context(), cid, fileID, "processing", map[string]any{"kind": kind})
pathStr, _ := meta["path"].(string)
abs, err := s.Catalog.ResolveUploadPath(s.Config.UploadDir, cid, pathStr)
if err != nil {
_, _ = s.Catalog.UpdateFileStatus(r.Context(), cid, fileID, "failed", map[string]any{"kind": kind, "error": "could not resolve upload"})
LogAndError(w, http.StatusInternalServerError, "could not resolve upload", err)
return
}
f, err := os.Open(abs)
if err != nil {
_, _ = s.Catalog.UpdateFileStatus(r.Context(), cid, fileID, "failed", map[string]any{"kind": kind, "error": "could not read upload"})
Error(w, http.StatusInternalServerError, "could not read upload")
return
}
defer f.Close()
var result any
switch kind {
case "categories":
result, err = s.Catalog.ImportCategoriesCSV(r.Context(), cid, f)
case "attributes":
result, err = s.Catalog.ImportAttributesCSV(r.Context(), cid, f)
case "products":
fid := fileID
result, err = s.Catalog.ImportProductsCSV(r.Context(), cid, f, &fid)
}
if err != nil {
public := "import failed"
if msg, ok := catalog.ClientError(err); ok {
public = msg
_, _ = s.Catalog.UpdateFileStatus(r.Context(), cid, fileID, "failed", map[string]any{"kind": kind, "error": public})
Error(w, http.StatusBadRequest, public)
return
}
_, _ = s.Catalog.UpdateFileStatus(r.Context(), cid, fileID, "failed", map[string]any{"kind": kind, "error": public})
LogAndError(w, http.StatusBadRequest, public, err)
return
}
importMeta := map[string]any{"kind": kind}
if ir, ok := result.(catalog.ImportResult); ok {
importMeta["created"] = ir.Created
importMeta["updated"] = ir.Updated
importMeta["skipped"] = ir.Skipped
importMeta["total_rows"] = ir.Created + ir.Updated + ir.Skipped
if len(ir.Errors) > 0 {
importMeta["errors"] = ir.Errors
}
}
meta, _ = s.Catalog.UpdateFileStatus(r.Context(), cid, fileID, "completed", importMeta)
JSON(w, http.StatusOK, map[string]any{"file": meta, "import": result, "kind": kind})
}
func importKindFromPath(r *http.Request) string {
if k := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("kind"))); k != "" {
return k
}
if k := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "kind"))); k != "" {
return k
}
path := r.URL.Path
switch {
case strings.Contains(path, "/categories/import"), strings.Contains(path, "/categories/upload"):
return "categories"
case strings.Contains(path, "/attributes/import"), strings.Contains(path, "/attributes/upload"):
return "attributes"
case strings.Contains(path, "/products/import"),
strings.Contains(path, "/products/upload-eans"),
strings.Contains(path, "/products/upload"):
return "products"
default:
return ""
}
}
@@ -0,0 +1,257 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func v1CatalogListMeta(page, limit int, total int64) map[string]any {
return v1ProductListMeta(page, limit, total)
}
func presentV1Category(item map[string]any) map[string]any {
return map[string]any{
"id": item["id"],
"unique_id": item["unique_id"],
"name": item["name"],
"created_at": formatV1Timestamp(item["created_at"]),
"updated_at": formatV1Timestamp(item["updated_at"]),
}
}
func presentV1Attribute(item map[string]any) map[string]any {
out := map[string]any{
"id": item["id"],
"key": item["attribute_key"],
"name": item["name"],
"type": item["value_type"],
"unit": item["unit"],
"required": false,
"created_at": formatV1Timestamp(item["created_at"]),
"updated_at": formatV1Timestamp(item["updated_at"]),
}
if v, ok := item["required"]; ok && v != nil {
switch t := v.(type) {
case bool:
out["required"] = t
}
}
if cid, ok := item["category_unique_id"]; ok && cid != nil && asMapString(cid) != "" {
out["category_unique_id"] = cid
}
return out
}
// handleV1ListCategories serves GET /api/v1/categories with legacy { data, meta }.
func (s *Server) handleV1ListCategories(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
page, limit, offset := ParsePageLimit(r)
items, total, err := s.Catalog.ListCategories(r.Context(), cid, catalog.ListFilter{
Query: QuerySearch(r),
Limit: limit,
Offset: offset,
})
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
data := make([]map[string]any, 0, len(items))
for _, item := range items {
data = append(data, presentV1Category(item))
}
v1OK(w, http.StatusOK, data, v1CatalogListMeta(page, limit, total))
}
// handleV1CreateCategory serves POST /api/v1/categories and /categories/create.
// Body: name + unique_id required; parent_id alias for parent_unique_id.
func (s *Server) handleV1CreateCategory(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var body struct {
Name string `json:"name"`
UniqueID string `json:"unique_id"`
ParentUniqueID *string `json:"parent_unique_id"`
ParentID *string `json:"parent_id"`
Description *string `json:"description"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
parent := body.ParentUniqueID
if (parent == nil || strings.TrimSpace(*parent) == "") && body.ParentID != nil {
parent = body.ParentID
}
item, err := s.Catalog.CreateCategory(r.Context(), cid, body.Name, body.UniqueID, parent, body.Description)
if err != nil {
if msg, ok := catalog.ClientError(err); ok {
Error(w, http.StatusBadRequest, msg)
return
}
ClientOrLog(w, http.StatusBadRequest, "could not create category", err, catalog.ClientError)
return
}
v1OK(w, http.StatusCreated, map[string]any{
"id": item["id"],
"unique_id": item["unique_id"],
"name": item["name"],
}, nil)
}
// handleV1DeleteCategory serves DELETE /api/v1/categories/{id} where {id} is unique_id.
func (s *Server) handleV1DeleteCategory(w http.ResponseWriter, r *http.Request) {
if !requireCompanyAdmin(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
uniqueID := strings.TrimSpace(chi.URLParam(r, "id"))
if uniqueID == "" {
Error(w, http.StatusBadRequest, "invalid category id")
return
}
if err := s.Catalog.DeleteCategoryByUniqueID(r.Context(), cid, uniqueID); err != nil {
if errors.Is(err, catalog.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if msg, ok := catalog.ClientError(err); ok {
Error(w, http.StatusBadRequest, msg)
return
}
Error(w, http.StatusInternalServerError, "delete failed")
return
}
v1OK(w, http.StatusOK, map[string]any{"message": "Category deleted successfully"}, nil)
}
// handleV1ListAttributes serves GET /api/v1/attributes with legacy { data, meta }.
func (s *Server) handleV1ListAttributes(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
page, limit, offset := ParsePageLimit(r)
f := catalog.ListFilter{
Query: QuerySearch(r),
Limit: limit,
Offset: offset,
Category: firstNonEmpty(r.URL.Query().Get("categoryId"), r.URL.Query().Get("category_id")),
RootsOnly: r.URL.Query().Get("roots") == "1",
ParentKey: firstNonEmpty(r.URL.Query().Get("parent_key"), r.URL.Query().Get("parentKey")),
SortBy: firstNonEmpty(r.URL.Query().Get("sortBy"), r.URL.Query().Get("sort_by"), "updatedAt"),
SortOrder: firstNonEmpty(r.URL.Query().Get("sortOrder"), r.URL.Query().Get("sort_order"), "desc"),
}
items, total, err := s.Catalog.ListAttributes(r.Context(), cid, f)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
data := make([]map[string]any, 0, len(items))
for _, item := range items {
data = append(data, presentV1Attribute(item))
}
v1OK(w, http.StatusOK, data, v1CatalogListMeta(page, limit, total))
}
// handleV1CreateAttribute serves POST /api/v1/attributes and /attributes/create.
// Requires name, attribute_key, value_type, category_unique_id (legacy contract).
func (s *Server) handleV1CreateAttribute(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var body struct {
Name string `json:"name"`
AttributeKey string `json:"attribute_key"`
ValueType string `json:"value_type"`
Unit *string `json:"unit"`
Example *string `json:"example"`
ParentKey *string `json:"parent_key"`
CategoryUniqueID string `json:"category_unique_id"`
Required bool `json:"required"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if strings.TrimSpace(body.Name) == "" || strings.TrimSpace(body.AttributeKey) == "" ||
strings.TrimSpace(body.ValueType) == "" || strings.TrimSpace(body.CategoryUniqueID) == "" {
Error(w, http.StatusBadRequest, "Missing required fields: name, attribute_key, value_type, category_unique_id")
return
}
item, err := s.Catalog.CreateAttribute(r.Context(), cid, body.AttributeKey, body.Name, body.ValueType, body.Unit, body.Example, body.ParentKey)
if err != nil {
if msg, ok := catalog.ClientError(err); ok {
Error(w, http.StatusBadRequest, msg)
return
}
ClientOrLog(w, http.StatusBadRequest, "could not create attribute", err, catalog.ClientError)
return
}
attrID, err := parseMapUUID(item["id"])
if err != nil {
Error(w, http.StatusInternalServerError, "could not create attribute")
return
}
if _, err := s.Catalog.LinkCategoryAttribute(r.Context(), cid, body.CategoryUniqueID, attrID, body.Required); err != nil {
if msg, ok := catalog.ClientError(err); ok {
status := http.StatusBadRequest
if strings.Contains(strings.ToLower(msg), "not found") {
status = http.StatusNotFound
}
Error(w, status, msg)
return
}
ClientOrLog(w, http.StatusBadRequest, "could not link attribute", err, catalog.ClientError)
return
}
v1OK(w, http.StatusCreated, map[string]any{
"id": item["id"],
"key": item["attribute_key"],
"name": item["name"],
"type": item["value_type"],
"unit": item["unit"],
"category_unique_id": body.CategoryUniqueID,
"required": body.Required,
}, nil)
}
// handleV1DeleteAttribute serves DELETE /api/v1/attributes/{id} (UUID).
func (s *Server) handleV1DeleteAttribute(w http.ResponseWriter, r *http.Request) {
if !requireCompanyAdmin(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid attribute id")
return
}
if err := s.Catalog.DeleteAttribute(r.Context(), cid, id); err != nil {
if errors.Is(err, catalog.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
Error(w, http.StatusInternalServerError, "delete failed")
return
}
v1OK(w, http.StatusOK, map[string]any{"message": "Attribute deleted successfully"}, nil)
}
func parseMapUUID(v any) (uuid.UUID, error) {
switch t := v.(type) {
case uuid.UUID:
return t, nil
case string:
return uuid.Parse(t)
case [16]byte:
return uuid.UUID(t), nil
default:
s := asMapString(v)
if s == "" {
return uuid.Nil, errors.New("invalid uuid")
}
return uuid.Parse(s)
}
}
@@ -0,0 +1,125 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
func TestPresentV1CategoryAndAttribute(t *testing.T) {
id := uuid.MustParse("33333333-3333-3333-3333-333333333333")
ts := time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC)
cat := presentV1Category(map[string]any{
"id": id, "unique_id": "electronics", "name": "Electronics",
"created_at": ts, "updated_at": ts,
})
if cat["unique_id"] != "electronics" || cat["name"] != "Electronics" {
t.Fatalf("category=%v", cat)
}
if cat["created_at"] != "2026-07-01T08:00:00Z" {
t.Fatalf("created_at=%v", cat["created_at"])
}
attr := presentV1Attribute(map[string]any{
"id": id, "attribute_key": "color", "name": "Color", "value_type": "string",
"unit": nil, "required": true, "category_unique_id": "electronics",
"created_at": ts, "updated_at": ts,
})
if attr["key"] != "color" || attr["type"] != "string" || attr["required"] != true {
t.Fatalf("attr=%v", attr)
}
if attr["category_unique_id"] != "electronics" {
t.Fatalf("missing category_unique_id: %v", attr)
}
}
func TestV1OpenAPICategoriesAttributesLegacyContract(t *testing.T) {
body := string(v1OpenAPIYAML)
needles := []string{
"LegacyCategoriesResponse",
"LegacyAttributesResponse",
"LegacyCategoryCreateResponse",
"LegacyAttributeCreateResponse",
"LegacySuccessMessage",
"category_unique_id",
"attribute_key",
"parent_id",
"/categories/create:",
"/attributes/create:",
"Delete category by unique_id",
"value_type:",
"enum: [string, number, list, multiselect]",
}
for _, n := range needles {
if !strings.Contains(body, n) {
t.Fatalf("openapi missing %q", n)
}
}
}
func TestV1CreateCategoryBodyAcceptsParentID(t *testing.T) {
payload := `{"name":"Headphones","unique_id":"headphones","parent_id":"audio","description":"x"}`
r := httptest.NewRequest(http.MethodPost, "/api/v1/categories", strings.NewReader(payload))
var body struct {
Name string `json:"name"`
UniqueID string `json:"unique_id"`
ParentUniqueID *string `json:"parent_unique_id"`
ParentID *string `json:"parent_id"`
Description *string `json:"description"`
}
if err := DecodeJSON(r, &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Name != "Headphones" || body.UniqueID != "headphones" || body.ParentID == nil || *body.ParentID != "audio" {
t.Fatalf("body=%+v", body)
}
}
func TestV1CreateAttributeBodyRequiresCategory(t *testing.T) {
payload := `{"name":"Color","attribute_key":"color","value_type":"string","category_unique_id":"electronics","required":true}`
r := httptest.NewRequest(http.MethodPost, "/api/v1/attributes", strings.NewReader(payload))
var body struct {
Name string `json:"name"`
AttributeKey string `json:"attribute_key"`
ValueType string `json:"value_type"`
CategoryUniqueID string `json:"category_unique_id"`
Required bool `json:"required"`
}
if err := DecodeJSON(r, &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.CategoryUniqueID != "electronics" || !body.Required || body.AttributeKey != "color" {
t.Fatalf("body=%+v", body)
}
}
func TestParseMapUUID(t *testing.T) {
id := uuid.MustParse("11111111-1111-1111-1111-111111111111")
got, err := parseMapUUID(id)
if err != nil || got != id {
t.Fatalf("uuid type: %v %v", got, err)
}
got, err = parseMapUUID(id.String())
if err != nil || got != id {
t.Fatalf("string: %v %v", got, err)
}
}
func TestV1CatalogListMetaJSON(t *testing.T) {
meta := v1CatalogListMeta(2, 25, 60)
b, err := json.Marshal(meta)
if err != nil {
t.Fatal(err)
}
s := string(b)
for _, n := range []string{`"page":2`, `"limit":25`, `"total":60`, `"totalPages":3`} {
if !strings.Contains(s, n) {
t.Fatalf("meta missing %s in %s", n, s)
}
}
}
@@ -0,0 +1,363 @@
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func (s *Server) handleGetCompany(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var (
id uuid.UUID
name, language string
merge bool
contentLangs []string
)
err := s.Pool.QueryRow(r.Context(), `
SELECT id, name, language, merge_products_by_gtin, COALESCE(content_languages, '{}')
FROM companies WHERE id = $1`, cid).
Scan(&id, &name, &language, &merge, &contentLangs)
if err != nil {
Error(w, http.StatusNotFound, "company not found")
return
}
parsed, _ := company.ParseContentLanguages(contentLangs, language)
JSON(w, http.StatusOK, map[string]any{
"id": id, "name": name, "language": language,
"content_languages": parsed, "merge_products_by_gtin": merge,
})
}
func (s *Server) handleUpdateCompany(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
return
}
var body struct {
Name *string `json:"name"`
Language *string `json:"language"`
ContentLanguages []string `json:"content_languages"`
MergeProductsByGTIN *bool `json:"merge_products_by_gtin"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
var languageArg any
primary := company.LoadLanguage(r.Context(), s.Pool, cid)
if body.Language != nil {
parsed, err := company.ParseLanguage(*body.Language, false)
if err != nil {
Error(w, http.StatusBadRequest, "unsupported language")
return
}
languageArg = parsed
primary = parsed
}
var contentLangsArg any
if body.ContentLanguages != nil {
parsed, err := company.ParseContentLanguages(body.ContentLanguages, primary)
if err != nil {
Error(w, http.StatusBadRequest, "unsupported language")
return
}
contentLangsArg = parsed
} else if body.Language != nil {
// Keep primary first when only language changes.
existing := company.LoadContentLanguages(r.Context(), s.Pool, cid)
parsed, err := company.ParseContentLanguages(existing, primary)
if err != nil {
parsed = []string{primary}
}
contentLangsArg = parsed
}
_, err := s.Pool.Exec(r.Context(), `
UPDATE companies SET
name = COALESCE($2, name),
language = COALESCE($3, language),
content_languages = COALESCE($5, content_languages),
merge_products_by_gtin = COALESCE($4, merge_products_by_gtin),
updated_at = now()
WHERE id = $1`, cid, body.Name, languageArg, body.MergeProductsByGTIN, contentLangsArg)
if err != nil {
Error(w, http.StatusInternalServerError, "update failed")
return
}
s.handleGetCompany(w, r)
}
func (s *Server) handleGetCompanySettings(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var settings []byte
err := s.Pool.QueryRow(r.Context(), `
SELECT settings FROM company_settings WHERE company_id = $1`, cid).Scan(&settings)
if err != nil {
JSON(w, http.StatusOK, map[string]any{"settings": map[string]any{}})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"settings":`))
_, _ = w.Write(settings)
_, _ = w.Write([]byte(`}`))
}
func (s *Server) handlePutCompanySettings(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
return
}
var body struct {
Settings map[string]any `json:"settings"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if err := company.ValidateSettingsMap(body.Settings); err != nil {
Error(w, http.StatusBadRequest, err.Error())
return
}
b, err := json.Marshal(body.Settings)
if err != nil {
Error(w, http.StatusBadRequest, "invalid settings")
return
}
_, err = s.Pool.Exec(r.Context(), `
INSERT INTO company_settings (company_id, settings, updated_at)
VALUES ($1, $2, now())
ON CONFLICT (company_id) DO UPDATE SET settings = EXCLUDED.settings, updated_at = now()`,
cid, b)
if err != nil {
Error(w, http.StatusInternalServerError, "save failed")
return
}
JSON(w, http.StatusOK, map[string]any{"settings": body.Settings})
}
func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
limit, offset := ParseLimitOffset(r)
var total int64
if err := s.Pool.QueryRow(r.Context(), `
SELECT count(*) FROM memberships m WHERE m.company_id = $1 AND m.status = 'active'`, cid).Scan(&total); err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
rows, err := s.Pool.Query(r.Context(), `
SELECT m.id, m.user_id, m.role, m.status, u.email, u.name
FROM memberships m JOIN users u ON u.id = m.user_id
WHERE m.company_id = $1 AND m.status = 'active' ORDER BY m.created_at LIMIT $2 OFFSET $3`, cid, limit, offset)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
defer rows.Close()
type member struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
Status string `json:"status"`
Email string `json:"email"`
Name *string `json:"name"`
}
out := make([]member, 0)
for rows.Next() {
var m member
if err := rows.Scan(&m.ID, &m.UserID, &m.Role, &m.Status, &m.Email, &m.Name); err != nil {
Error(w, http.StatusInternalServerError, "scan failed")
return
}
out = append(out, m)
}
JSON(w, http.StatusOK, map[string]any{"members": out, "total": total, "limit": limit, "offset": offset})
}
func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) {
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
uid, _ := UserIDFromContext(r.Context())
var body struct {
Email string `json:"email"`
Role string `json:"role"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
inv, token, err := s.Auth.CreateInvite(r.Context(), cid, uid, body.Email, body.Role)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not create invite", err, auth.ClientError)
return
}
companyName, _ := s.Auth.CompanyName(r.Context(), cid)
smtpOn := s.Mail != nil && s.Mail.Enabled()
sendOK := false
if s.Mail != nil {
msg := mail.InviteMessage(s.Config.WebOrigin, inv.Email, token, companyName)
if err := s.Mail.Send(msg); err == nil {
sendOK = true
}
}
// noop/disabled mailers return nil from Send; only count real SMTP as delivered.
mailSent, includeToken := inviteMailResult(smtpOn, sendOK)
resp := map[string]any{
"id": inv.ID, "email": inv.Email, "role": inv.Role,
"expires_at": inv.ExpiresAt, "mail_sent": mailSent, "smtp_enabled": smtpOn,
}
// Token returned when email was not delivered so operators can share the accept link.
if includeToken {
resp["token"] = token
}
JSON(w, http.StatusCreated, resp)
}
// inviteMailResult decides mail_sent and whether the accept token must be returned to the client.
func inviteMailResult(smtpEnabled, sendOK bool) (mailSent bool, includeToken bool) {
mailSent = smtpEnabled && sendOK
includeToken = !mailSent
return
}
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
userID, err := uuid.Parse(chi.URLParam(r, "userID"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid user id")
return
}
var currentRole, status string
err = s.Pool.QueryRow(r.Context(), `
SELECT role, status FROM memberships
WHERE company_id = $1 AND user_id = $2`, cid, userID).Scan(&currentRole, &status)
if errors.Is(err, pgx.ErrNoRows) {
Error(w, http.StatusNotFound, "member not found")
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "lookup failed")
return
}
if status == "active" && auth.NormalizeMembershipRole(currentRole) == "admin" {
var activeAdmins int64
if err := s.Pool.QueryRow(r.Context(), `
SELECT count(*) FROM memberships
WHERE company_id = $1 AND role = 'admin' AND status = 'active'`, cid).Scan(&activeAdmins); err != nil {
Error(w, http.StatusInternalServerError, "lookup failed")
return
}
if blocksLastAdminRemove(activeAdmins) {
Error(w, http.StatusConflict, "cannot remove the last admin")
return
}
}
tag, err := s.Pool.Exec(r.Context(), `
UPDATE memberships SET status = 'inactive', updated_at = now()
WHERE company_id = $1 AND user_id = $2 AND status = 'active'`, cid, userID)
if err != nil {
Error(w, http.StatusInternalServerError, "remove failed")
return
}
if tag.RowsAffected() == 0 {
Error(w, http.StatusNotFound, "member not found")
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// blocksLastAdminDemote is true when demoting an admin would leave zero active admins.
func blocksLastAdminDemote(currentRole, newRole string, activeAdminCount int64) bool {
return currentRole == "admin" && newRole == "member" && activeAdminCount <= 1
}
// blocksLastAdminRemove is true when removing an admin would leave zero active admins.
func blocksLastAdminRemove(activeAdminCount int64) bool {
return activeAdminCount <= 1
}
func (s *Server) handleUpdateMemberRole(w http.ResponseWriter, r *http.Request) {
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
userID, err := uuid.Parse(chi.URLParam(r, "userID"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid user id")
return
}
var body struct {
Role string `json:"role"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
newRole, err := auth.ParseMembershipRole(body.Role)
if err != nil {
Error(w, http.StatusBadRequest, "invalid role")
return
}
var currentRole, status string
err = s.Pool.QueryRow(r.Context(), `
SELECT role, status FROM memberships
WHERE company_id = $1 AND user_id = $2`, cid, userID).Scan(&currentRole, &status)
if errors.Is(err, pgx.ErrNoRows) {
Error(w, http.StatusNotFound, "member not found")
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "lookup failed")
return
}
if status != "active" {
Error(w, http.StatusBadRequest, "member is not active")
return
}
currentRole = auth.NormalizeMembershipRole(currentRole)
if currentRole == newRole {
JSON(w, http.StatusOK, map[string]any{"status": "ok", "role": newRole, "user_id": userID})
return
}
if currentRole == "admin" && newRole == "member" {
var activeAdmins int64
if err := s.Pool.QueryRow(r.Context(), `
SELECT count(*) FROM memberships
WHERE company_id = $1 AND role = 'admin' AND status = 'active'`, cid).Scan(&activeAdmins); err != nil {
Error(w, http.StatusInternalServerError, "lookup failed")
return
}
if blocksLastAdminDemote(currentRole, newRole, activeAdmins) {
Error(w, http.StatusConflict, "cannot demote the last admin")
return
}
}
tag, err := s.Pool.Exec(r.Context(), `
UPDATE memberships SET role = $3, updated_at = now()
WHERE company_id = $1 AND user_id = $2 AND status = 'active'`, cid, userID, newRole)
if err != nil {
Error(w, http.StatusInternalServerError, "update failed")
return
}
if tag.RowsAffected() == 0 {
Error(w, http.StatusNotFound, "member not found")
return
}
JSON(w, http.StatusOK, map[string]any{"status": "ok", "role": newRole, "user_id": userID})
}
@@ -0,0 +1,31 @@
package httpapi
import "testing"
func TestInviteMailResult(t *testing.T) {
t.Parallel()
cases := []struct {
name string
smtpEnabled bool
sendOK bool
wantMailSent bool
wantToken bool
}{
{name: "smtp_ok", smtpEnabled: true, sendOK: true, wantMailSent: true, wantToken: false},
{name: "smtp_send_fail", smtpEnabled: true, sendOK: false, wantMailSent: false, wantToken: true},
{name: "noop_mailer_send_ok", smtpEnabled: false, sendOK: true, wantMailSent: false, wantToken: true},
{name: "disabled_no_send", smtpEnabled: false, sendOK: false, wantMailSent: false, wantToken: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
mailSent, includeToken := inviteMailResult(tc.smtpEnabled, tc.sendOK)
if mailSent != tc.wantMailSent {
t.Fatalf("mailSent=%v want %v", mailSent, tc.wantMailSent)
}
if includeToken != tc.wantToken {
t.Fatalf("includeToken=%v want %v", includeToken, tc.wantToken)
}
})
}
}
@@ -0,0 +1,212 @@
package httpapi
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func TestBlocksLastAdminDemote(t *testing.T) {
t.Parallel()
cases := []struct {
name string
currentRole string
newRole string
activeAdminCount int64
want bool
}{
{name: "demote_last_admin", currentRole: "admin", newRole: "member", activeAdminCount: 1, want: true},
{name: "demote_zero_admins", currentRole: "admin", newRole: "member", activeAdminCount: 0, want: true},
{name: "demote_with_other_admins", currentRole: "admin", newRole: "member", activeAdminCount: 2, want: false},
{name: "promote_member", currentRole: "member", newRole: "admin", activeAdminCount: 1, want: false},
{name: "noop_admin", currentRole: "admin", newRole: "admin", activeAdminCount: 1, want: false},
{name: "noop_member", currentRole: "member", newRole: "member", activeAdminCount: 0, want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := blocksLastAdminDemote(tc.currentRole, tc.newRole, tc.activeAdminCount)
if got != tc.want {
t.Fatalf("blocksLastAdminDemote(%q,%q,%d)=%v want %v",
tc.currentRole, tc.newRole, tc.activeAdminCount, got, tc.want)
}
})
}
}
func TestBlocksLastAdminRemove(t *testing.T) {
t.Parallel()
if !blocksLastAdminRemove(1) {
t.Fatal("expected last admin remove blocked")
}
if !blocksLastAdminRemove(0) {
t.Fatal("expected zero admins remove blocked")
}
if blocksLastAdminRemove(2) {
t.Fatal("expected remove allowed when other admins remain")
}
}
func TestUpdateMemberRoleRejectsInvalidRole(t *testing.T) {
t.Parallel()
s := &Server{}
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "admin")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("userID", uid.String())
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
req := httptest.NewRequest(http.MethodPatch, "/api/team/"+uid.String(), bytes.NewBufferString(`{"role":"owner"}`))
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
s.handleUpdateMemberRole(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s, want 400", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "invalid role") {
t.Fatalf("body = %s, want invalid role", rec.Body.String())
}
}
func TestUpdateMemberRoleForbiddenForMember(t *testing.T) {
t.Parallel()
s := &Server{}
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "member")
req := httptest.NewRequest(http.MethodPatch, "/api/team/"+uid.String(), bytes.NewBufferString(`{"role":"admin"}`))
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
s.handleUpdateMemberRole(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d body=%s, want 403", rec.Code, rec.Body.String())
}
}
func TestAllowCompanyAdminOrPlatform(t *testing.T) {
t.Parallel()
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
t.Run("company_admin", func(t *testing.T) {
t.Parallel()
s := &Server{}
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxRole, "admin")
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
rec := httptest.NewRecorder()
if !s.allowCompanyAdminOrPlatform(rec, req) {
t.Fatal("company admin should be allowed")
}
})
t.Run("member_denied", func(t *testing.T) {
t.Parallel()
s := &Server{
testPlatformAdmin: func(context.Context, uuid.UUID) (bool, error) {
return false, nil
},
}
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxRole, "member")
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
rec := httptest.NewRecorder()
if s.allowCompanyAdminOrPlatform(rec, req) {
t.Fatal("member without platform admin must be denied")
}
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
})
t.Run("platform_admin_member_role", func(t *testing.T) {
t.Parallel()
s := &Server{
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
if got != uid {
t.Fatalf("userID = %s, want %s", got, uid)
}
return true, nil
},
}
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxRole, "member")
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
rec := httptest.NewRecorder()
if !s.allowCompanyAdminOrPlatform(rec, req) {
t.Fatal("platform admin with membership role=member must be allowed for cutover")
}
})
t.Run("dev_impersonator_retains_admin", func(t *testing.T) {
t.Parallel()
actor := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
sm := scs.New()
s := &Server{
Config: config.Config{AppEnv: "development"},
Sessions: sm,
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
return got == actor, nil
},
}
var token string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
sm.Put(r.Context(), auth.SessionImpersonatorIDKey, actor.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
token = c.Value
}
}
if token == "" {
t.Fatal("expected session cookie")
}
rec := httptest.NewRecorder()
LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxRole, "member")
req := r.WithContext(ctx)
if !s.allowCompanyAdminOrPlatform(w, req) {
t.Fatal("impersonating privileged actor must retain company-admin powers")
}
w.WriteHeader(http.StatusNoContent)
})).ServeHTTP(rec, func() *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
return req
}())
if rec.Code != http.StatusNoContent {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
})
t.Run("dev_impersonator_helper_empty_session", func(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{AppEnv: "development"}}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(context.WithValue(req.Context(), ctxUserID, uid))
if s.devImpersonatorRetainsCompanyAdmin(req) {
t.Fatal("nil Sessions must not retain admin")
}
})
}
@@ -0,0 +1,84 @@
package httpapi
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
)
func TestPutCompanySettingsRejectsUnknownKey(t *testing.T) {
t.Parallel()
s := &Server{}
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "admin")
req := httptest.NewRequest(
http.MethodPut,
"/api/company/settings",
bytes.NewBufferString(`{"settings":{"prefs.theme":"dark","language":"en"}}`),
)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
s.handlePutCompanySettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s, want 400", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "unknown settings key") {
t.Fatalf("body = %s, want unknown settings key", rec.Body.String())
}
}
func TestPutCompanySettingsRejectsInvalidLanguage(t *testing.T) {
t.Parallel()
s := &Server{}
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "admin")
req := httptest.NewRequest(
http.MethodPut,
"/api/company/settings",
bytes.NewBufferString(`{"settings":{"language":"not-a-lang"}}`),
)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
s.handlePutCompanySettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s, want 400", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "unsupported language") {
t.Fatalf("body = %s, want unsupported language", rec.Body.String())
}
}
func TestPutCompanySettingsForbiddenForMember(t *testing.T) {
t.Parallel()
s := &Server{}
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "member")
req := httptest.NewRequest(
http.MethodPut,
"/api/company/settings",
bytes.NewBufferString(`{"settings":{"language":"en"}}`),
)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
s.handlePutCompanySettings(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d body=%s, want 403", rec.Code, rec.Body.String())
}
}
+278
View File
@@ -0,0 +1,278 @@
package httpapi
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
)
func testServerCSRF() *Server {
sm := scs.New()
sm.Cookie.Name = "descrybe_session"
return &Server{
Config: config.Config{
CSRFCookieName: "descrybe_csrf",
SessionSecure: false,
},
Sessions: sm,
Auth: &auth.Service{},
}
}
func testServerCSRFSecure(secure bool, appEnv string) *Server {
s := testServerCSRF()
s.Config.SessionSecure = secure
s.Config.AppEnv = appEnv
return s
}
func findCSRFCookie(cookies []*http.Cookie) *http.Cookie {
for _, c := range cookies {
if c.Name == "descrybe_csrf" && c.Value != "" {
return c
}
}
return nil
}
func TestCSRFAllowsSafeMethodsWithoutHeader(t *testing.T) {
t.Parallel()
s := testServerCSRF()
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("GET status = %d, want 204", rec.Code)
}
found := false
for _, c := range rec.Result().Cookies() {
if c.Name == "descrybe_csrf" && c.Value != "" && !c.HttpOnly {
found = true
}
}
if !found {
t.Fatal("expected non-HttpOnly CSRF cookie on first GET")
}
}
func TestCSRFRejectsPOSTWithoutToken(t *testing.T) {
t.Parallel()
s := testServerCSRF()
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
for _, path := range []string{
"/api/auth/login",
"/api/auth/forgot-password",
"/api/auth/reset-password",
} {
req := httptest.NewRequest(http.MethodPost, path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("%s POST without CSRF status = %d, want 403", path, rec.Code)
}
}
}
func TestCSRFAcceptsMatchingHeader(t *testing.T) {
t.Parallel()
s := testServerCSRF()
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "ok")
}))
getReq := httptest.NewRequest(http.MethodGet, "/healthz", nil)
getRec := httptest.NewRecorder()
h.ServeHTTP(getRec, getReq)
var token string
for _, c := range getRec.Result().Cookies() {
if c.Name == "descrybe_csrf" {
token = c.Value
}
}
if token == "" {
t.Fatal("missing CSRF cookie from GET")
}
postReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
postReq.AddCookie(&http.Cookie{Name: "descrybe_csrf", Value: token})
postReq.Header.Set("X-CSRF-Token", token)
postRec := httptest.NewRecorder()
h.ServeHTTP(postRec, postReq)
if postRec.Code != http.StatusOK {
t.Fatalf("POST with CSRF status = %d, want 200", postRec.Code)
}
}
func TestCSRFRejectsMismatchedHeader(t *testing.T) {
t.Parallel()
s := testServerCSRF()
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
req.AddCookie(&http.Cookie{Name: "descrybe_csrf", Value: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})
req.Header.Set("X-CSRF-Token", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("mismatched CSRF status = %d, want 403", rec.Code)
}
}
func TestCSRFCookieAttributesDev(t *testing.T) {
t.Parallel()
s := testServerCSRFSecure(false, "development")
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
c := findCSRFCookie(rec.Result().Cookies())
if c == nil {
t.Fatal("expected CSRF cookie")
}
if c.HttpOnly {
t.Fatal("CSRF cookie must not be HttpOnly (double-submit)")
}
if c.Secure {
t.Fatal("development without SessionSecure should not set Secure")
}
if c.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want Lax", c.SameSite)
}
if c.Path != "/" {
t.Fatalf("Path = %q, want /", c.Path)
}
if c.MaxAge != 7*24*60*60 {
t.Fatalf("MaxAge = %d, want 7d", c.MaxAge)
}
}
func TestCSRFCookieSecureWhenSessionSecure(t *testing.T) {
t.Parallel()
s := testServerCSRFSecure(true, "development")
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
c := findCSRFCookie(rec.Result().Cookies())
if c == nil {
t.Fatal("expected CSRF cookie")
}
if !c.Secure {
t.Fatal("SessionSecure=true should set Secure")
}
if c.HttpOnly {
t.Fatal("CSRF cookie must not be HttpOnly")
}
if c.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want Lax", c.SameSite)
}
}
func TestCSRFCookieSecureWhenProductionAppEnv(t *testing.T) {
t.Parallel()
// Defense in depth: APP_ENV=production forces Secure even if SessionSecure was left false.
s := testServerCSRFSecure(false, "production")
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
c := findCSRFCookie(rec.Result().Cookies())
if c == nil {
t.Fatal("expected CSRF cookie")
}
if !c.Secure {
t.Fatal("APP_ENV=production must set Secure via CookieSecure")
}
}
// Client-mint path: SPA sets descrybe_csrf locally; middleware must accept matching header+cookie
// without a prior server-issued Set-Cookie on this request.
func TestCSRFAcceptsClientMintedCookie(t *testing.T) {
t.Parallel()
s := testServerCSRF()
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
const token = "0123456789abcdef0123456789abcdef"
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
req.AddCookie(&http.Cookie{Name: "descrybe_csrf", Value: token})
req.Header.Set("X-CSRF-Token", token)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("client-minted CSRF status = %d, want 204", rec.Code)
}
}
func TestCSRFExemptPathSegmentsOnly(t *testing.T) {
t.Parallel()
cases := []struct {
path string
exempt bool
}{
{"/api/v1", true},
{"/api/v1/products", true},
{"/api/v10", false},
{"/api/v1legacy", false},
{"/api/public", true},
{"/api/public/plans", true},
{"/api/publicish", false},
{"/api/webhooks", true},
{"/api/webhooks/stripe", true},
{"/api/webhooksx", false},
{"/api/auth/login", false},
{"/api/auth/forgot-password", false},
{"/api/auth/reset-password", false},
}
for _, tc := range cases {
if got := csrfExemptPath(tc.path); got != tc.exempt {
t.Fatalf("csrfExemptPath(%q) = %v, want %v", tc.path, got, tc.exempt)
}
}
}
func TestCSRFRequiresTokenOnV1LookalikePath(t *testing.T) {
t.Parallel()
s := testServerCSRF()
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodPost, "/api/v10/mutate", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (lookalike must not skip CSRF)", rec.Code)
}
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/products", nil)
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusNoContent {
t.Fatalf("v1 exempt status = %d, want 204", rec2.Code)
}
}
+241
View File
@@ -0,0 +1,241 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/campaigns"
"github.com/descrybe/descrybe-v2/apps/api/internal/email"
"github.com/jackc/pgx/v5"
)
func (s *Server) handleGetEmailIntegration(w http.ResponseWriter, r *http.Request) {
if s.Email == nil {
Error(w, http.StatusServiceUnavailable, "email integration unavailable")
return
}
cid, _ := CompanyIDFromContext(r.Context())
cfg, err := s.Email.GetConfig(r.Context(), cid)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to load email settings")
return
}
JSON(w, http.StatusOK, cfg)
}
func (s *Server) handlePutEmailIntegration(w http.ResponseWriter, r *http.Request) {
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
return
}
if s.Email == nil {
Error(w, http.StatusServiceUnavailable, "email integration unavailable")
return
}
cid, _ := CompanyIDFromContext(r.Context())
var body email.UpdateInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
cfg, err := s.Email.UpdateConfig(r.Context(), cid, body)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update email settings", err, email.ClientError)
return
}
JSON(w, http.StatusOK, cfg)
}
func (s *Server) handleVerifyEmailIntegration(w http.ResponseWriter, r *http.Request) {
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
return
}
if s.Email == nil {
Error(w, http.StatusServiceUnavailable, "email integration unavailable")
return
}
cid, _ := CompanyIDFromContext(r.Context())
cfg, msg, err := s.Email.VerifyDomain(r.Context(), cid)
if errors.Is(err, email.ErrNotConfigured) {
Error(w, http.StatusBadRequest, "email provider not configured")
return
}
if errors.Is(err, email.ErrProviderMisconfig) {
Error(w, http.StatusBadRequest, "email provider credentials incomplete")
return
}
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "email verification failed", err, email.ClientError)
return
}
JSON(w, http.StatusOK, map[string]any{"config": cfg, "message": msg})
}
func (s *Server) handleTestEmailIntegration(w http.ResponseWriter, r *http.Request) {
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
return
}
if s.Email == nil {
Error(w, http.StatusServiceUnavailable, "email integration unavailable")
return
}
cid, _ := CompanyIDFromContext(r.Context())
var body struct {
To string `json:"to"`
Subject string `json:"subject"`
Text string `json:"text"`
HTML string `json:"html"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
to := strings.TrimSpace(body.To)
if to == "" {
Error(w, http.StatusBadRequest, "to is required")
return
}
normalized, nerr := campaigns.NormalizeEmail(to)
if nerr != nil {
Error(w, http.StatusBadRequest, "invalid email")
return
}
to = normalized
// Fixed probe content — do not accept client HTML/subject (header injection / phishing via test).
result, err := s.Email.Send(r.Context(), cid, email.SendRequest{
To: []string{to},
Subject: "Descrybe email test",
Text: "This is a Descrybe email provider test.",
HTML: "<p>This is a Descrybe email provider test.</p>",
Mode: "test",
})
if err != nil {
writeEmailSendError(w, err)
return
}
JSON(w, http.StatusOK, result)
}
func (s *Server) handleSendEmail(w http.ResponseWriter, r *http.Request) {
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
return
}
if s.Email == nil {
Error(w, http.StatusServiceUnavailable, "email integration unavailable")
return
}
cid, _ := CompanyIDFromContext(r.Context())
if s.Billing != nil {
if err := s.Billing.AssertFeatures(r.Context(), cid, "capability.email_live_send", "integrations.email.blast"); err != nil {
if writePlanGate(w, err) {
return
}
Error(w, http.StatusInternalServerError, "feature check failed")
return
}
}
var body email.SendRequest
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
result, err := s.Email.Send(r.Context(), cid, body)
if err != nil {
writeEmailSendError(w, err)
return
}
JSON(w, http.StatusOK, result)
}
func writeEmailSendError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, email.ErrMissingConfirm):
Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, email.ErrNotVerified):
Error(w, http.StatusPreconditionFailed, "email_not_verified")
case errors.Is(err, email.ErrNotConfigured):
Error(w, http.StatusBadRequest, "email provider not configured")
case errors.Is(err, email.ErrNotEnabled):
Error(w, http.StatusBadRequest, "email provider is disabled")
case errors.Is(err, email.ErrRateLimited):
w.Header().Set("Retry-After", "60")
Error(w, http.StatusTooManyRequests, "rate limit exceeded")
case errors.Is(err, email.ErrProviderMisconfig):
Error(w, http.StatusBadRequest, "email provider credentials incomplete")
default:
LogAndError(w, http.StatusBadRequest, "email send failed", err)
}
}
func (s *Server) handlePublicUnsubscribeGet(w http.ResponseWriter, r *http.Request) {
if s.Email == nil {
Error(w, http.StatusServiceUnavailable, "email integration unavailable")
return
}
token := strings.TrimSpace(r.URL.Query().Get("token"))
_, emailAddr, already, err := s.Email.LookupUnsubscribeToken(r.Context(), token)
if errors.Is(err, pgx.ErrNoRows) {
Error(w, http.StatusNotFound, "invalid unsubscribe token")
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "lookup failed")
return
}
// Mask in response — one-click clients only need status.
_ = emailAddr
JSON(w, http.StatusOK, map[string]any{
"ok": true,
"already_unsubscribed": already,
"supports_one_click": true,
})
}
func (s *Server) handlePublicUnsubscribePost(w http.ResponseWriter, r *http.Request) {
if s.Email == nil {
Error(w, http.StatusServiceUnavailable, "email integration unavailable")
return
}
token := strings.TrimSpace(r.URL.Query().Get("token"))
reason := ""
if r.Header.Get("Content-Type") != "" && strings.Contains(r.Header.Get("Content-Type"), "application/json") {
var body struct {
Token string `json:"token"`
Reason string `json:"reason"`
}
if err := DecodeJSONOptional(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if body.Token != "" {
token = body.Token
}
reason = body.Reason
} else if token == "" {
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
if err := r.ParseForm(); err != nil {
Error(w, http.StatusBadRequest, "invalid form")
return
}
token = strings.TrimSpace(r.Form.Get("token"))
reason = strings.TrimSpace(r.Form.Get("reason"))
}
info, err := s.Email.UnsubscribeByToken(r.Context(), token, reason)
if err != nil {
Error(w, http.StatusInternalServerError, "unsubscribe failed")
return
}
if !info.OK {
Error(w, http.StatusNotFound, info.Message)
return
}
JSON(w, http.StatusOK, info)
}
@@ -0,0 +1,47 @@
package httpapi
import (
"fmt"
"net/http"
"strconv"
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func (s *Server) handleExportSelectedProducts(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
feedID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
ProductIDs []string `json:"product_ids"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
ids := make([]uuid.UUID, 0, len(body.ProductIDs))
for _, raw := range body.ProductIDs {
id, err := uuid.Parse(raw)
if err != nil {
Error(w, http.StatusBadRequest, "invalid product_ids")
return
}
ids = append(ids, id)
}
filename, mimeType, content, count, err := s.Feeds.ExportSelectedProducts(r.Context(), cid, feedID, ids)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not export selected products", err, feeds.ClientError)
return
}
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.Header().Set("X-Products-Exported", strconv.Itoa(count))
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(content)
}
+616
View File
@@ -0,0 +1,616 @@
package httpapi
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func (s *Server) handleListFeeds(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
limit, offset := ParseLimitOffset(r)
page, total, activeTotal, mappedTotal, err := s.Feeds.List(r.Context(), cid, limit, offset, QuerySearch(r))
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
products, err := s.Feeds.CompanyProductTotals(r.Context(), cid)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{
"feeds": feeds.PresentFeeds(page), "total": total, "active_total": activeTotal, "mapped_total": mappedTotal,
"product_total": products.Total, "processed_total": products.Processed, "unprocessed_total": products.Unprocessed,
"limit": limit, "offset": offset,
})
}
func (s *Server) handleCreateFeed(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
uid, _ := UserIDFromContext(r.Context())
ct := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type")))
if strings.HasPrefix(ct, "multipart/form-data") {
s.createFeedFromMultipart(w, r, cid, uid)
return
}
var body struct {
Name string `json:"name"`
URL string `json:"url"`
ItemPath string `json:"item_path"`
FeedType string `json:"feed_type"`
SyncIntervalMinutes int `json:"sync_interval_minutes"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Feeds.Create(r.Context(), cid, feeds.CreateInput{
Name: body.Name,
URL: body.URL,
ItemPath: body.ItemPath,
FeedType: body.FeedType,
SyncIntervalMinutes: body.SyncIntervalMinutes,
})
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not create feed", err, feeds.ClientError)
return
}
JSON(w, http.StatusCreated, feeds.PresentFeed(item))
}
func (s *Server) createFeedFromMultipart(w http.ResponseWriter, r *http.Request, cid, uid uuid.UUID) {
if err := r.ParseMultipartForm(catalogMaxUpload); err != nil {
Error(w, http.StatusBadRequest, "invalid multipart form")
return
}
name := strings.TrimSpace(r.FormValue("name"))
url := strings.TrimSpace(r.FormValue("url"))
feedType := strings.TrimSpace(r.FormValue("feed_type"))
itemPath := strings.TrimSpace(r.FormValue("item_path"))
interval, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("sync_interval_minutes")))
file, header, fileErr := r.FormFile("file")
var options map[string]any
if fileErr == nil {
defer file.Close()
meta, err := s.Catalog.SaveUpload(
r.Context(),
cid,
uid,
s.Config.UploadDir,
header.Filename,
header.Header.Get("Content-Type"),
"feed",
file,
)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not save upload", err, catalog.ClientError)
return
}
pathStr, _ := meta["path"].(string)
fileID, _ := meta["id"].(string)
fileName, _ := meta["name"].(string)
options = map[string]any{
"source_path": pathStr,
"source_file_id": fileID,
"source_filename": fileName,
"source_kind": "csv",
}
if feedType == "" {
feedType = "csv"
}
if fid, err := uuid.Parse(fileID); err == nil {
_, _ = s.Catalog.UpdateFileStatus(r.Context(), cid, fid, "uploaded", map[string]any{
"kind": "feed",
"feed": true,
"name": name,
})
}
} else if url == "" && itemPath == "" {
Error(w, http.StatusBadRequest, "url or file field required")
return
}
item, err := s.Feeds.Create(r.Context(), cid, feeds.CreateInput{
Name: name,
URL: url,
ItemPath: itemPath,
FeedType: feedType,
SyncIntervalMinutes: interval,
Options: options,
})
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not create feed", err, feeds.ClientError)
return
}
JSON(w, http.StatusCreated, feeds.PresentFeed(item))
}
func (s *Server) handleGetFeed(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Feeds.Get(r.Context(), cid, id)
if err != nil {
if feeds.IsNotFound(err) {
Error(w, http.StatusNotFound, "not found")
return
}
Error(w, http.StatusInternalServerError, "get failed")
return
}
JSON(w, http.StatusOK, feeds.PresentFeed(item))
}
func (s *Server) handleUpdateFeed(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body map[string]any
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Feeds.Update(r.Context(), cid, id, body)
if err != nil {
if feeds.IsNotFound(err) {
Error(w, http.StatusNotFound, "not found")
return
}
ClientOrLog(w, http.StatusBadRequest, "could not update feed", err, feeds.ClientError)
return
}
JSON(w, http.StatusOK, feeds.PresentFeed(item))
}
func (s *Server) handleDeleteFeed(w http.ResponseWriter, r *http.Request) {
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.Feeds.Delete(r.Context(), cid, id); err != nil {
if feeds.IsNotFound(err) {
Error(w, http.StatusNotFound, "not found")
return
}
Error(w, http.StatusInternalServerError, "delete failed")
return
}
JSON(w, http.StatusOK, map[string]any{"id": id.String(), "deleted": true})
}
func (s *Server) handleSyncFeed(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
jobID, err := s.Feeds.EnqueueSync(r.Context(), cid, id)
if err != nil {
if feeds.IsNotFound(err) {
Error(w, http.StatusNotFound, "Feed not found")
return
}
ClientOrLog(w, http.StatusBadRequest, "could not sync feed", err, feeds.ClientError)
return
}
if s.Jobs != nil {
_ = s.Jobs.EnqueueFeedSyncJob(r.Context(), jobID)
}
job, err := s.Feeds.GetSyncJob(r.Context(), cid, id, jobID)
if err != nil {
Error(w, http.StatusInternalServerError, "could not load sync job")
return
}
JSON(w, http.StatusAccepted, job)
}
func (s *Server) handleListSyncJobs(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
limit, _ := ParseLimitOffset(r)
items, err := s.Feeds.ListSyncJobs(r.Context(), cid, id, limit)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{"jobs": items, "limit": limit})
}
func (s *Server) handleGetSyncJob(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
feedID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
jobID, err := uuid.Parse(chi.URLParam(r, "jobID"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid job id")
return
}
job, err := s.Feeds.GetSyncJob(r.Context(), cid, feedID, jobID)
if err != nil {
Error(w, http.StatusNotFound, "not found")
return
}
JSON(w, http.StatusOK, job)
}
func (s *Server) handleGetFeedMappings(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Feeds.GetMappings(r.Context(), cid, id)
if err != nil {
JSON(w, http.StatusOK, map[string]any{"mappings": []any{}})
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handlePutFeedMappings(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
Mappings any `json:"mappings"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Feeds.PutMappings(r.Context(), cid, id, body.Mappings)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not save mappings", err, feeds.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleExtractFeedSchema(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
ItemPath string `json:"item_path"`
}
if err := DecodeJSONOptional(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
result, err := s.Feeds.ExtractSchema(r.Context(), cid, id, body.ItemPath)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not extract schema", err, feeds.ClientError)
return
}
JSON(w, http.StatusOK, result)
}
// handleSyncAndProcessSample syncs a company-scoped feed then starts a processing job
// for up to N of that feed's raw products (default 10, max 100).
func (s *Server) handleSyncAndProcessSample(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
uid, _ := UserIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
Limit int `json:"limit"`
SkipSync bool `json:"skip_sync"`
ProcessingType string `json:"processing_type"`
}
if err := DecodeJSONOptional(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
limit := body.Limit
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
processingType := strings.TrimSpace(body.ProcessingType)
if processingType == "" {
processingType = "full"
}
var syncJob map[string]any
if !body.SkipSync {
job, syncErr := s.Feeds.Sync(r.Context(), cid, id)
if syncErr != nil {
ClientOrLog(w, http.StatusBadRequest, "could not sync feed", syncErr, feeds.ClientError)
return
}
syncJob = job
}
rawIDs, err := s.Catalog.ListRawProductIDsByFeed(r.Context(), cid, id, limit)
if err != nil {
Error(w, http.StatusInternalServerError, "list raw products failed")
return
}
if len(rawIDs) == 0 {
JSON(w, http.StatusOK, map[string]any{
"sync_job": syncJob,
"processing_job": nil,
"raw_product_ids": []string{},
"sample_requested": limit,
"sample_queued": 0,
"message": "Sync completed but no raw products found for this feed",
})
return
}
procJobs, err := s.Processing.StartJob(r.Context(), cid, uid, rawIDs, processingType)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not start processing", err, processing.ClientError)
return
}
for _, procJob := range procJobs {
if err := s.Jobs.EnqueueProcessingJob(r.Context(), procJob.ID); err != nil {
Error(w, http.StatusInternalServerError, "enqueue failed")
return
}
}
var primary any
if len(procJobs) > 0 {
primary = processing.FormatStartJobsResponse(procJobs)
}
idStrs := make([]string, 0, len(rawIDs))
for _, rid := range rawIDs {
idStrs = append(idStrs, rid.String())
}
JSON(w, http.StatusAccepted, map[string]any{
"sync_job": syncJob,
"processing_job": primary,
"processing_jobs": procJobs,
"raw_product_ids": idStrs,
"sample_requested": limit,
"sample_queued": len(rawIDs),
"message": fmt.Sprintf("Queued %d product(s) for processing", len(rawIDs)),
})
}
func (s *Server) handleListExportFeeds(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
limit, offset := ParseLimitOffset(r)
page, total, err := s.Feeds.ListExportFeeds(r.Context(), cid, limit, offset)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{"export_feeds": page, "total": total, "limit": limit, "offset": offset})
}
func (s *Server) handleCreateExportFeed(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var body struct {
Name string `json:"name"`
SourceFeedID *string `json:"source_feed_id"`
Format string `json:"format"`
Template any `json:"template"`
Filters any `json:"filters"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Feeds.CreateExportFeed(r.Context(), cid, feeds.CreateExportInput{
Name: body.Name, SourceFeedID: body.SourceFeedID, Format: body.Format,
Template: body.Template, Filters: body.Filters,
})
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not create export feed", err, feeds.ClientError)
return
}
JSON(w, http.StatusCreated, item)
}
func (s *Server) handleGetExportFeed(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Feeds.GetExportFeed(r.Context(), cid, id)
if err != nil {
Error(w, http.StatusNotFound, "not found")
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleUpdateExportFeed(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
Name *string `json:"name"`
IsActive *bool `json:"is_active"`
Template any `json:"template"`
Filters any `json:"filters"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Feeds.UpdateExportFeed(r.Context(), cid, id, body.Name, body.IsActive, body.Template, body.Filters)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update export feed", err, feeds.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleUpdateExportFeedTemplate(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
Template any `json:"template"`
Filters any `json:"filters"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Feeds.UpdateExportFeedTemplate(r.Context(), cid, id, body.Template, body.Filters)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update export template", err, feeds.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleDeleteExportFeed(w http.ResponseWriter, r *http.Request) {
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.Feeds.DeleteExportFeed(r.Context(), cid, id); err != nil {
Error(w, http.StatusNotFound, "not found")
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleRotateExportFeedPublicToken(w http.ResponseWriter, r *http.Request) {
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Feeds.RotateExportFeedPublicToken(r.Context(), cid, id)
if err != nil {
Error(w, http.StatusNotFound, "not found")
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleGenerateExportFeed(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Feeds.GenerateExportFeed(r.Context(), cid, id)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not generate export", err, feeds.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handlePublicExportXML(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
lw := &lazyHeaderWriter{ResponseWriter: w, contentType: "application/xml; charset=utf-8"}
if err := s.Feeds.PublicExportXML(r.Context(), lw, token); err != nil {
if !lw.wrote {
writePublicExportError(w, err)
}
return
}
}
func (s *Server) handlePublicExportCSV(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
lw := &lazyHeaderWriter{ResponseWriter: w, contentType: "text/csv; charset=utf-8"}
if err := s.Feeds.PublicExportCSV(r.Context(), lw, token); err != nil {
if !lw.wrote {
writePublicExportError(w, err)
}
return
}
}
func writePublicExportError(w http.ResponseWriter, err error) {
// Format mismatch must look identical to unknown tokens so probing .xml/.csv
// cannot confirm whether a guessed public_token exists.
switch {
case errors.Is(err, feeds.ErrFormatMismatch), errors.Is(err, pgx.ErrNoRows):
Error(w, http.StatusNotFound, "export feed not found")
default:
Error(w, http.StatusNotFound, "export feed not found")
}
}
type lazyHeaderWriter struct {
http.ResponseWriter
contentType string
wrote bool
}
func (l *lazyHeaderWriter) Write(p []byte) (int, error) {
if !l.wrote {
l.Header().Set("Content-Type", l.contentType)
l.wrote = true
}
return l.ResponseWriter.Write(p)
}
func (l *lazyHeaderWriter) Flush() {
if f, ok := l.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
+89
View File
@@ -0,0 +1,89 @@
package httpapi
import (
"context"
"net/http"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/jobs"
)
const healthServiceName = "api"
// dbPinger is satisfied by *pgxpool.Pool; kept narrow for unit tests.
type dbPinger interface {
Ping(ctx context.Context) error
}
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
JSON(w, http.StatusOK, map[string]any{
"status": "ok",
"service": healthServiceName,
"maintenance": s.Config.MaintenanceMode,
"read_only": s.Config.ReadOnlyMode,
"hypercare": s.Config.HypercareMode,
})
}
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
var pinger dbPinger
var prober jobs.HeartbeatQuerier
if s.Pool != nil {
pinger = s.Pool
prober = s.Pool
}
s.writeReadyz(w, r, pinger, prober)
}
func (s *Server) writeReadyz(w http.ResponseWriter, r *http.Request, pinger dbPinger, prober jobs.HeartbeatQuerier) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
ok, check, errMsg := databaseReady(ctx, pinger)
checks := map[string]string{"database": check}
body := map[string]any{
"status": "ready",
"service": healthServiceName,
"maintenance": s.Config.MaintenanceMode,
"read_only": s.Config.ReadOnlyMode,
"hypercare": s.Config.HypercareMode,
"checks": checks,
}
if !ok {
body["status"] = "not_ready"
body["error"] = errMsg
JSON(w, http.StatusServiceUnavailable, body)
return
}
probe := jobs.ProbeWorkerReadiness(ctx, prober, jobs.DefaultHeartbeatStaleAfter)
checks["worker"] = probe.WorkerCheck
checks["queue"] = probe.QueueCheck
body["queue_pending"] = probe.PendingJobs
if probe.LastSeenAgeS >= 0 {
body["worker_last_seen_age_s"] = probe.LastSeenAgeS
}
if !probe.OK {
body["status"] = "not_ready"
body["error"] = probe.ErrMsg
if probe.Reason != "" {
body["reason"] = probe.Reason
}
JSON(w, http.StatusServiceUnavailable, body)
return
}
JSON(w, http.StatusOK, body)
}
// databaseReady pings Postgres for readiness. check is "ok", "unavailable", or "fail".
// errMsg is empty when ok; never includes driver detail (safe for public probes).
func databaseReady(ctx context.Context, p dbPinger) (ok bool, check string, errMsg string) {
if p == nil {
return false, "unavailable", "database pool unavailable"
}
if err := p.Ping(ctx); err != nil {
return false, "fail", "database ping failed"
}
return true, "ok", ""
}
+431
View File
@@ -0,0 +1,431 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/descrybe/descrybe-v2/apps/api/internal/jobs"
"github.com/jackc/pgx/v5"
)
type stubPinger struct{ err error }
func (p stubPinger) Ping(context.Context) error { return p.err }
type stubHBRow struct {
scan func(dest ...any) error
}
func (r stubHBRow) Scan(dest ...any) error {
if r.scan == nil {
return pgx.ErrNoRows
}
return r.scan(dest...)
}
type stubHeartbeat struct {
pending int64
lastSeen time.Time
seenErr error
calls int
}
func (q *stubHeartbeat) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
q.calls++
if q.calls == 1 {
return stubHBRow{scan: func(dest ...any) error {
*(dest[0].(*int64)) = q.pending
return nil
}}
}
return stubHBRow{scan: func(dest ...any) error {
if q.seenErr != nil {
return q.seenErr
}
*(dest[0].(*time.Time)) = q.lastSeen
return nil
}}
}
func liveWorkerProbe() jobs.HeartbeatQuerier {
return &stubHeartbeat{pending: 2, lastSeen: time.Now()}
}
func TestHandleHealthzOK(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{MaintenanceMode: true, ReadOnlyMode: true, HypercareMode: true}}
rec := httptest.NewRecorder()
s.handleHealthz(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["status"] != "ok" {
t.Fatalf("body = %#v", body)
}
if body["service"] != healthServiceName {
t.Fatalf("service = %#v", body["service"])
}
if body["maintenance"] != true || body["read_only"] != true || body["hypercare"] != true {
t.Fatalf("expected maintenance/read_only/hypercare flags, got %#v", body)
}
}
func TestHandleReadyzNilPool(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{MaintenanceMode: true, ReadOnlyMode: true}, Pool: nil}
rec := httptest.NewRecorder()
s.handleReadyz(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["status"] != "not_ready" {
t.Fatalf("body = %#v", body)
}
if body["service"] != healthServiceName {
t.Fatalf("service = %#v", body["service"])
}
if body["maintenance"] != true || body["read_only"] != true {
t.Fatalf("expected flags on 503, got %#v", body)
}
checks, _ := body["checks"].(map[string]any)
if checks["database"] != "unavailable" {
t.Fatalf("checks = %#v", body["checks"])
}
if body["error"] != "database pool unavailable" {
t.Fatalf("error = %#v", body["error"])
}
}
func TestWriteReadyzPingOK(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{ReadOnlyMode: true}}
rec := httptest.NewRecorder()
s.writeReadyz(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil), stubPinger{}, liveWorkerProbe())
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["status"] != "ready" || body["service"] != healthServiceName {
t.Fatalf("body = %#v", body)
}
if body["read_only"] != true {
t.Fatalf("read_only = %#v", body["read_only"])
}
checks, _ := body["checks"].(map[string]any)
if checks["database"] != "ok" || checks["worker"] != "ok" || checks["queue"] != "ok" {
t.Fatalf("checks = %#v", body["checks"])
}
if body["queue_pending"] != float64(2) {
t.Fatalf("queue_pending = %#v", body["queue_pending"])
}
}
func TestWriteReadyzWorkerStale(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{}}
rec := httptest.NewRecorder()
stale := &stubHeartbeat{pending: 5, lastSeen: time.Now().Add(-2 * time.Minute)}
s.writeReadyz(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil), stubPinger{}, stale)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 body=%s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
checks, _ := body["checks"].(map[string]any)
if body["status"] != "not_ready" || checks["worker"] != "stale" || checks["database"] != "ok" {
t.Fatalf("body = %#v", body)
}
if body["queue_pending"] != float64(5) {
t.Fatalf("queue_pending = %#v", body["queue_pending"])
}
if body["error"] != "worker heartbeat stale" {
t.Fatalf("error = %#v", body["error"])
}
reason, _ := body["reason"].(string)
if reason == "" || !strings.Contains(reason, "npm run dev") {
t.Fatalf("reason = %#v", body["reason"])
}
}
func TestWriteReadyzPingFail(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{}}
rec := httptest.NewRecorder()
s.writeReadyz(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil), stubPinger{err: errors.New("boom")}, liveWorkerProbe())
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
checks, _ := body["checks"].(map[string]any)
if body["status"] != "not_ready" || checks["database"] != "fail" {
t.Fatalf("body = %#v", body)
}
if body["error"] != "database ping failed" {
t.Fatalf("error leaked detail: %#v", body["error"])
}
}
func TestDatabaseReady(t *testing.T) {
t.Parallel()
ctx := context.Background()
ok, check, msg := databaseReady(ctx, nil)
if ok || check != "unavailable" || msg == "" {
t.Fatalf("nil pinger: ok=%v check=%s msg=%q", ok, check, msg)
}
ok, check, msg = databaseReady(ctx, stubPinger{err: errors.New("x")})
if ok || check != "fail" || msg != "database ping failed" {
t.Fatalf("fail pinger: ok=%v check=%s msg=%q", ok, check, msg)
}
ok, check, msg = databaseReady(ctx, stubPinger{})
if !ok || check != "ok" || msg != "" {
t.Fatalf("ok pinger: ok=%v check=%s msg=%q", ok, check, msg)
}
}
func TestMaintenanceGateBlocksNonHealth(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{MaintenanceMode: true}}
h := s.MaintenanceGate(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
blocked := httptest.NewRecorder()
h.ServeHTTP(blocked, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
if blocked.Code != http.StatusServiceUnavailable {
t.Fatalf("blocked status = %d", blocked.Code)
}
assertGateBody(t, blocked, "maintenance", true, false)
ok := httptest.NewRecorder()
h.ServeHTTP(ok, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if ok.Code != http.StatusOK {
t.Fatalf("health status = %d", ok.Code)
}
ready := httptest.NewRecorder()
h.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if ready.Code != http.StatusOK {
t.Fatalf("readyz status = %d", ready.Code)
}
// Query string must not defeat the probe exemption (Path is still /healthz).
probeQ := httptest.NewRecorder()
h.ServeHTTP(probeQ, httptest.NewRequest(http.MethodGet, "/healthz?ping=1", nil))
if probeQ.Code != http.StatusOK {
t.Fatalf("healthz?query status = %d", probeQ.Code)
}
}
func TestReadOnlyGateBlocksMutations(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{ReadOnlyMode: true}}
h := s.MaintenanceGate(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(method, "/api/products", nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s status = %d, want 200", method, rec.Code)
}
}
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(method, "/api/products", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("%s status = %d, want 503", method, rec.Code)
}
assertGateBody(t, rec, "read_only", false, true)
}
}
func TestMaintenanceGatePrecedenceOverReadOnly(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{MaintenanceMode: true, ReadOnlyMode: true}}
h := s.MaintenanceGate(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
getRec := httptest.NewRecorder()
h.ServeHTTP(getRec, httptest.NewRequest(http.MethodGet, "/api/products", nil))
if getRec.Code != http.StatusServiceUnavailable {
t.Fatalf("GET status = %d, want 503", getRec.Code)
}
assertGateBody(t, getRec, "maintenance", true, true)
postRec := httptest.NewRecorder()
h.ServeHTTP(postRec, httptest.NewRequest(http.MethodPost, "/api/products", nil))
if postRec.Code != http.StatusServiceUnavailable {
t.Fatalf("POST status = %d, want 503", postRec.Code)
}
assertGateBody(t, postRec, "maintenance", true, true)
ok := httptest.NewRecorder()
h.ServeHTTP(ok, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if ok.Code != http.StatusOK {
t.Fatalf("readyz status = %d", ok.Code)
}
}
func TestRouterMaintenanceAndReadOnlyBeforeCSRF(t *testing.T) {
t.Parallel()
maint := testAPIServer()
maint.Config.MaintenanceMode = true
maintH := maint.Router()
maintPOST := httptest.NewRecorder()
maintH.ServeHTTP(maintPOST, httptest.NewRequest(http.MethodPost, "/api/auth/login", nil))
if maintPOST.Code != http.StatusServiceUnavailable {
t.Fatalf("maintenance POST without CSRF status = %d, want 503 (not csrf 403); body=%s", maintPOST.Code, maintPOST.Body.String())
}
assertGateBody(t, maintPOST, "maintenance", true, false)
maintGET := httptest.NewRecorder()
maintH.ServeHTTP(maintGET, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
if maintGET.Code != http.StatusServiceUnavailable {
t.Fatalf("maintenance GET status = %d, want 503", maintGET.Code)
}
assertGateBody(t, maintGET, "maintenance", true, false)
health := httptest.NewRecorder()
maintH.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if health.Code != http.StatusOK {
t.Fatalf("healthz under maintenance status = %d", health.Code)
}
var healthBody map[string]any
if err := json.Unmarshal(health.Body.Bytes(), &healthBody); err != nil {
t.Fatal(err)
}
if healthBody["maintenance"] != true {
t.Fatalf("healthz flags = %#v", healthBody)
}
ro := testAPIServer()
ro.Config.ReadOnlyMode = true
roH := ro.Router()
roPOST := httptest.NewRecorder()
roH.ServeHTTP(roPOST, httptest.NewRequest(http.MethodPost, "/api/auth/login", nil))
if roPOST.Code != http.StatusServiceUnavailable {
t.Fatalf("read-only POST without CSRF status = %d, want 503 (not csrf 403); body=%s", roPOST.Code, roPOST.Body.String())
}
assertGateBody(t, roPOST, "read_only", false, true)
roGET := httptest.NewRecorder()
roH.ServeHTTP(roGET, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
if roGET.Code == http.StatusServiceUnavailable {
t.Fatalf("read-only GET must pass the gate; got 503 body=%s", roGET.Body.String())
}
if roGET.Code != http.StatusUnauthorized {
t.Fatalf("read-only GET /api/auth/me status = %d, want 401", roGET.Code)
}
}
func TestRouterMetricsMounted(t *testing.T) {
t.Parallel()
h := testAPIServer().Router()
// Drive one request so RED counters are non-empty.
health := httptest.NewRecorder()
h.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if health.Code != http.StatusOK {
t.Fatalf("healthz status=%d", health.Code)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if rec.Code != http.StatusOK {
t.Fatalf("metrics status=%d body=%s", rec.Code, rec.Body.String())
}
ct := rec.Header().Get("Content-Type")
if !strings.Contains(ct, "text/plain") {
t.Fatalf("Content-Type=%q", ct)
}
body := rec.Body.String()
for _, want := range []string{
"http_requests_total{",
`path="/healthz"`,
"# TYPE sync_failures_total counter",
} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q in metrics:\n%s", want, body)
}
}
}
func TestRouterMetricsHiddenInProductionForRemote(t *testing.T) {
t.Parallel()
s := testAPIServer()
s.Config.AppEnv = "production"
s.Config.MetricsPublic = false
h := s.Router()
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
req.RemoteAddr = "203.0.113.9:9999"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("prod remote metrics status=%d want 404", rec.Code)
}
loop := httptest.NewRequest(http.MethodGet, "/metrics", nil)
loop.RemoteAddr = "127.0.0.1:4242"
recLoop := httptest.NewRecorder()
h.ServeHTTP(recLoop, loop)
if recLoop.Code != http.StatusOK {
t.Fatalf("prod loopback metrics status=%d", recLoop.Code)
}
s.Config.MetricsPublic = true
hPub := s.Router()
reqPub := httptest.NewRequest(http.MethodGet, "/metrics", nil)
reqPub.RemoteAddr = "203.0.113.9:9999"
recPub := httptest.NewRecorder()
hPub.ServeHTTP(recPub, reqPub)
if recPub.Code != http.StatusOK {
t.Fatalf("METRICS_PUBLIC remote status=%d", recPub.Code)
}
}
func assertGateBody(t *testing.T, rec *httptest.ResponseRecorder, errorCode string, maintenance, readOnly bool) {
t.Helper()
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("json: %v body=%s", err, rec.Body.String())
}
if body["error"] != errorCode {
t.Fatalf("error = %#v, want %q", body["error"], errorCode)
}
if body["maintenance"] != maintenance {
t.Fatalf("maintenance = %#v, want %v", body["maintenance"], maintenance)
}
if body["read_only"] != readOnly {
t.Fatalf("read_only = %#v, want %v", body["read_only"], readOnly)
}
}
@@ -0,0 +1,64 @@
package httpapi
import (
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/i18n"
)
// localeResponseWriter carries the resolved UI/API locale for Error()/CodedError.
type localeResponseWriter struct {
http.ResponseWriter
locale string
}
func (w *localeResponseWriter) Locale() string { return w.locale }
func (w *localeResponseWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
type localeCarrier interface {
Locale() string
}
type responseUnwrapper interface {
Unwrap() http.ResponseWriter
}
func localeOf(w http.ResponseWriter) string {
for w != nil {
if lc, ok := w.(localeCarrier); ok {
return lc.Locale()
}
uw, ok := w.(responseUnwrapper)
if !ok {
break
}
w = uw.Unwrap()
}
return i18n.Default
}
// Locale resolves Accept-Language into a supported UI locale, stores it on the
// request context and ResponseWriter, and sets Vary: Accept-Language.
func Locale(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lang := i18n.Resolve(r.Header.Get("Accept-Language"))
ctx := i18n.WithLocale(r.Context(), lang)
if v := w.Header().Get("Vary"); v == "" {
w.Header().Set("Vary", "Accept-Language")
} else if !containsCSVToken(v, "Accept-Language") {
w.Header().Set("Vary", v+", Accept-Language")
}
next.ServeHTTP(&localeResponseWriter{ResponseWriter: w, locale: lang}, r.WithContext(ctx))
})
}
func containsCSVToken(header, token string) bool {
for _, part := range strings.Split(header, ",") {
if strings.TrimSpace(part) == token {
return true
}
}
return false
}
@@ -0,0 +1,112 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestErrorLocalizesWithAcceptLanguage(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Accept-Language", "nl")
Locale(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusUnauthorized, "unauthorized")
})).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status=%d", rec.Code)
}
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["error"] != "niet geautoriseerd" {
t.Fatalf("error=%q", body["error"])
}
if got := rec.Header().Get("Vary"); got != "Accept-Language" {
t.Fatalf("Vary=%q", got)
}
}
func TestErrorKeepsStableMachineCodes(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Accept-Language", "fr")
Locale(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusForbidden, "password_not_set")
})).ServeHTTP(rec, req)
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["error"] != "password_not_set" {
t.Fatalf("stable code changed: %q", body["error"])
}
}
func TestCodedErrorLocalizesMessageOnly(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Accept-Language", "de")
Locale(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
CodedError(w, http.StatusUnauthorized, "invalid_api_key", "invalid api key")
})).ServeHTTP(rec, req)
var body map[string]any
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
errObj, ok := body["error"].(map[string]any)
if !ok {
t.Fatalf("shape=%#v", body)
}
if errObj["code"] != "invalid_api_key" {
t.Fatalf("code=%v", errObj["code"])
}
if errObj["message"] != "ungültiger API-Schlüssel" {
t.Fatalf("message=%v", errObj["message"])
}
}
func TestFieldErrorLocalizesWithAcceptLanguage(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/x", nil)
req.Header.Set("Accept-Language", "nl")
Locale(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
FieldError(w, http.StatusUnauthorized, "unauthorized", "invalid_credentials", map[string]string{
"email": "unauthorized",
"password": "unauthorized",
})
})).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status=%d", rec.Code)
}
var body struct {
Error string `json:"error"`
Code string `json:"code"`
Fields map[string]string `json:"fields"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Error != "niet geautoriseerd" {
t.Fatalf("error=%q", body.Error)
}
if body.Code != "invalid_credentials" {
t.Fatalf("code=%q (must stay stable)", body.Code)
}
if body.Fields["email"] != "niet geautoriseerd" || body.Fields["password"] != "niet geautoriseerd" {
t.Fatalf("fields=%v", body.Fields)
}
if got := rec.Header().Get("Vary"); got != "Accept-Language" {
t.Fatalf("Vary=%q", got)
}
}
+135
View File
@@ -0,0 +1,135 @@
package httpapi
import (
"strings"
"sync"
"time"
)
// Email-keyed login lockout (in-process, per API replica).
//
// Complements IP RateLimitAuth: rotating IPs still hit the same email budget.
// ASSUMPTION (Product 10): a single API instance (or acknowledged per-replica
// memory) is acceptable — same posture as HTTP rate limiters in ratelimit.go.
// RATE_LIMIT_REPLICAS does not divide this lockout; multi-replica hard caps need edge/WAF.
// Captcha is deferred; lockout + IP RPM are the primary login abuse controls.
const (
loginLockoutMaxFails = 5
loginLockoutDuration = 15 * time.Minute
)
type loginLockState struct {
fails int
windowStart time.Time
lockedUntil time.Time
}
// loginAttemptLockout tracks failed password attempts by normalized email.
type loginAttemptLockout struct {
mu sync.Mutex
maxFails int
lockFor time.Duration
state map[string]*loginLockState
}
func newLoginAttemptLockout(maxFails int, lockFor time.Duration) *loginAttemptLockout {
if maxFails < 1 {
maxFails = loginLockoutMaxFails
}
if lockFor <= 0 {
lockFor = loginLockoutDuration
}
return &loginAttemptLockout{
maxFails: maxFails,
lockFor: lockFor,
state: make(map[string]*loginLockState),
}
}
func normalizeLoginEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// locked reports whether email is currently locked and Retry-After seconds.
func (l *loginAttemptLockout) locked(email string) (bool, int) {
key := normalizeLoginEmail(email)
if key == "" || l == nil {
return false, 0
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
st := l.state[key]
if st == nil {
return false, 0
}
if st.lockedUntil.After(now) {
sec := int(st.lockedUntil.Sub(now).Seconds()) + 1
if sec < 1 {
sec = 1
}
return true, sec
}
if !st.lockedUntil.IsZero() && !st.lockedUntil.After(now) {
// Lock expired — reset failure window.
delete(l.state, key)
}
return false, 0
}
// recordFailure increments the failure count for email; locks after maxFails
// within the lock window. No-ops for empty email.
func (l *loginAttemptLockout) recordFailure(email string) {
key := normalizeLoginEmail(email)
if key == "" || l == nil {
return
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
st := l.state[key]
if st == nil {
st = &loginLockState{windowStart: now}
l.state[key] = st
}
if st.lockedUntil.After(now) {
return
}
if !st.lockedUntil.IsZero() && !st.lockedUntil.After(now) {
st.fails = 0
st.windowStart = now
st.lockedUntil = time.Time{}
}
if now.Sub(st.windowStart) > l.lockFor {
st.fails = 0
st.windowStart = now
}
st.fails++
if st.fails >= l.maxFails {
st.lockedUntil = now.Add(l.lockFor)
st.fails = 0
st.windowStart = now
}
}
// clear resets failures and lock for email (successful login).
func (l *loginAttemptLockout) clear(email string) {
key := normalizeLoginEmail(email)
if key == "" || l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
delete(l.state, key)
}
func (s *Server) loginAttempts() *loginAttemptLockout {
if s == nil {
return newLoginAttemptLockout(loginLockoutMaxFails, loginLockoutDuration)
}
s.loginLockoutOnce.Do(func() {
s.loginLockout = newLoginAttemptLockout(loginLockoutMaxFails, loginLockoutDuration)
})
return s.loginLockout
}
@@ -0,0 +1,91 @@
package httpapi
import (
"testing"
"time"
)
func TestLoginAttemptLockoutLocksAfterMaxFails(t *testing.T) {
t.Parallel()
l := newLoginAttemptLockout(3, 100*time.Millisecond)
email := "Victim@Example.com"
for i := 0; i < 2; i++ {
l.recordFailure(email)
if locked, _ := l.locked(email); locked {
t.Fatalf("unexpected lock after %d failures", i+1)
}
}
l.recordFailure(email)
locked, retry := l.locked("victim@example.com")
if !locked {
t.Fatal("expected lock after max failures")
}
if retry < 1 {
t.Fatalf("retry-after want >=1 got %d", retry)
}
// Case-normalized key: different casing still locked.
if locked2, _ := l.locked("VICTIM@EXAMPLE.COM"); !locked2 {
t.Fatal("expected lock for normalized email")
}
// Other emails are independent.
if locked3, _ := l.locked("other@example.com"); locked3 {
t.Fatal("other email should not be locked")
}
}
func TestLoginAttemptLockoutClearOnSuccess(t *testing.T) {
t.Parallel()
l := newLoginAttemptLockout(2, time.Minute)
email := "user@example.com"
l.recordFailure(email)
l.clear(email)
if locked, _ := l.locked(email); locked {
t.Fatal("clear should remove lock state")
}
l.recordFailure(email)
if locked, _ := l.locked(email); locked {
t.Fatal("one failure after clear should not lock (max=2)")
}
}
func TestLoginAttemptLockoutExpires(t *testing.T) {
t.Parallel()
l := newLoginAttemptLockout(1, 30*time.Millisecond)
email := "temp@example.com"
l.recordFailure(email)
if locked, _ := l.locked(email); !locked {
t.Fatal("expected immediate lock at maxFails=1")
}
time.Sleep(45 * time.Millisecond)
if locked, _ := l.locked(email); locked {
t.Fatal("expected lock to expire")
}
}
func TestLoginAttemptLockoutIgnoresEmptyEmail(t *testing.T) {
t.Parallel()
l := newLoginAttemptLockout(1, time.Minute)
l.recordFailure(" ")
if locked, _ := l.locked(" "); locked {
t.Fatal("empty email must not lock")
}
}
func TestServerLoginAttemptsLazyInit(t *testing.T) {
t.Parallel()
s := &Server{}
a := s.loginAttempts()
b := s.loginAttempts()
if a == nil || a != b {
t.Fatal("loginAttempts should lazy-init once")
}
a.recordFailure("a@example.com")
a.recordFailure("a@example.com")
a.recordFailure("a@example.com")
a.recordFailure("a@example.com")
a.recordFailure("a@example.com")
if locked, _ := b.locked("a@example.com"); !locked {
t.Fatal("shared lockout state expected on Server")
}
}
@@ -0,0 +1,144 @@
package httpapi
import (
"net/http"
"strconv"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/marketing"
)
func (s *Server) marketingService() *marketing.Service {
return &marketing.Service{Pool: s.Pool, Feeds: s.Feeds}
}
func (s *Server) handleGetMarketingCalendar(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
year := time.Now().UTC().Year()
if y := r.URL.Query().Get("year"); y != "" {
parsed, err := strconv.Atoi(y)
if err != nil || parsed < 2000 || parsed > 2100 {
Error(w, http.StatusBadRequest, "invalid year")
return
}
year = parsed
}
prepared, err := s.marketingService().ListPreparedCampaigns(r.Context(), cid)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{
"year": year,
"presets": marketing.ListPresets(year),
"prepared": prepared,
})
}
func (s *Server) handlePrepareMarketingCalendar(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var body struct {
PresetID string `json:"preset_id"`
Year int `json:"year"`
Format string `json:"format"`
ForceNew bool `json:"force_new"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
campaign, err := s.marketingService().PrepareCampaign(r.Context(), cid, marketing.PrepareInput{
PresetID: marketing.PresetID(body.PresetID),
Year: body.Year,
Format: body.Format,
ForceNew: body.ForceNew,
})
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not prepare campaign", err, marketing.ClientError)
return
}
status := http.StatusOK
if campaign.Created {
status = http.StatusCreated
}
JSON(w, status, campaign)
}
func (s *Server) handleListProductQuality(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
limit, offset := ParseLimitOffset(r)
var minScore *int
if raw := r.URL.Query().Get("min_score"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil {
Error(w, http.StatusBadRequest, "invalid min_score")
return
}
minScore = &n
}
f := catalog.ListFilter{
Query: QuerySearch(r),
Status: r.URL.Query().Get("status"),
Category: r.URL.Query().Get("category"),
FeedID: firstNonEmpty(r.URL.Query().Get("feed_id"), r.URL.Query().Get("feedId")),
Limit: limit,
Offset: offset,
}
if f.Status == "" {
f.Status = "completed"
}
items, total, err := s.Catalog.ListProcessedProductsDetailed(r.Context(), cid, f)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
q := marketing.ScoreFromProductMap(item)
if minScore != nil && q.Score < *minScore {
continue
}
out = append(out, map[string]any{
"id": item["id"],
"product_id": item["product_id"],
"name": firstNonEmpty(asMapString(item["processed_name"]), asMapString(item["name"])),
"quality_score": q.Score,
"quality_grade": q.Grade,
"quality_checks": q.Checks,
})
}
JSON(w, http.StatusOK, map[string]any{
"products": out,
"total": total,
"limit": limit,
"offset": offset,
})
}
func asMapString(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
func attachProductQuality(items []map[string]any) {
for i := range items {
q := marketing.ScoreFromProductMap(items[i])
items[i]["quality_score"] = q.Score
items[i]["quality_grade"] = q.Grade
items[i]["quality_checks"] = q.Checks
// Drop heavy fields used only for scoring when present on list payloads.
delete(items[i], "mapped_data")
delete(items[i], "attributes")
delete(items[i], "processed_attributes")
delete(items[i], "description")
delete(items[i], "processed_description")
delete(items[i], "meta_title")
delete(items[i], "meta_description")
}
}
@@ -0,0 +1,28 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestRouterV1MCPInstallGone locks MCP removal: GET /api/v1/mcp/install.json
// must be absent from the public router (chi 404), not a live install snippet.
func TestRouterV1MCPInstallGone(t *testing.T) {
t.Parallel()
s := testAPIServer()
h := s.Router()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/mcp/install.json", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("mcp install status=%d want 404 body=%s", rec.Code, rec.Body.String())
}
// OpenAPI must remain public after MCP removal.
openAPI := httptest.NewRecorder()
h.ServeHTTP(openAPI, httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil))
if openAPI.Code != http.StatusOK {
t.Fatalf("openapi status=%d want 200", openAPI.Code)
}
}
+411
View File
@@ -0,0 +1,411 @@
package httpapi
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"errors"
"net/http"
"strings"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/google/uuid"
)
type ctxKey string
const (
ctxUserID ctxKey = "user_id"
ctxCompanyID ctxKey = "company_id"
ctxRole ctxKey = "role"
ctxStaffAccess ctxKey = "staff_access"
)
func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
v, ok := ctx.Value(ctxUserID).(uuid.UUID)
return v, ok
}
func CompanyIDFromContext(ctx context.Context) (uuid.UUID, bool) {
v, ok := ctx.Value(ctxCompanyID).(uuid.UUID)
return v, ok
}
func RoleFromContext(ctx context.Context) (string, bool) {
v, ok := ctx.Value(ctxRole).(string)
return v, ok
}
// CompanyAdminAllowed reports whether the caller may perform company-admin
// mutations. Session role "admin" and API-key auth role "api" (admin-owned keys
// only — see apiKeyContextRole) are allowed; members are not.
func CompanyAdminAllowed(ctx context.Context) bool {
role, _ := RoleFromContext(ctx)
return role == "admin" || role == "api"
}
// apiKeyContextRole maps the key owner's membership role onto the request role.
// Admin-owned keys keep legacy "api" privileges (CompanyAdminAllowed). Non-admin
// owners keep membership role so product reset / admin-gated deletes stay closed.
// Full scopes + expiry are deferred: api_keys has no scopes/expires_at columns yet;
// dashboard creation remains admin-only (allowCompanyAdminOrPlatform).
func apiKeyContextRole(membershipRole string) string {
if auth.NormalizeMembershipRole(membershipRole) == "admin" {
return "api"
}
return auth.NormalizeMembershipRole(membershipRole)
}
func requireCompanyAdmin(w http.ResponseWriter, r *http.Request) bool {
if CompanyAdminAllowed(r.Context()) {
return true
}
Error(w, http.StatusForbidden, "admin required")
return false
}
// allowCompanyAdminOrPlatform allows company admins, API keys, or platform admins.
// Platform admins can manage team after migration when all memberships are still "member".
// Non-prod: while a privileged demo/platform actor is impersonating, retain company-admin powers
// so local user-switch can still create API keys and manage the tenant.
func (s *Server) allowCompanyAdminOrPlatform(w http.ResponseWriter, r *http.Request) bool {
if CompanyAdminAllowed(r.Context()) {
return true
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return false
}
isAdmin, err := s.checkPlatformAdmin(r.Context(), uid)
if err != nil {
Error(w, http.StatusInternalServerError, "authorization check failed")
return false
}
if isAdmin {
return true
}
if s.devImpersonatorRetainsCompanyAdmin(r) {
return true
}
Error(w, http.StatusForbidden, "admin required")
return false
}
// devImpersonatorRetainsCompanyAdmin is true in non-production when the session is
// impersonating and the stored actor is still a privileged demo/platform admin.
func (s *Server) devImpersonatorRetainsCompanyAdmin(r *http.Request) bool {
if s.Config.IsProduction() || s.Sessions == nil {
return false
}
impStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionImpersonatorIDKey))
if impStr == "" {
return false
}
impID, err := uuid.Parse(impStr)
if err != nil || impID == uuid.Nil {
return false
}
access, err := s.checkStaffAccess(r.Context(), impID)
if err == nil && access.FullAdmin {
return true
}
if s.Auth == nil {
return false
}
impUser, err := s.Auth.GetUser(r.Context(), impID)
return err == nil && isLocalDemoEmail(impUser.Email)
}
func (s *Server) RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uidStr := s.Sessions.GetString(r.Context(), auth.SessionUserIDKey)
if uidStr == "" {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
uid, err := uuid.Parse(uidStr)
if err != nil {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
sessionVersion := s.Sessions.GetInt(r.Context(), auth.SessionVersionKey)
if active, checked, err := s.sessionUserIsActive(r.Context(), uid, sessionVersion); err != nil || (checked && !active) {
_ = s.Sessions.Destroy(r.Context())
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
ctx := context.WithValue(r.Context(), ctxUserID, uid)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// sessionUserIsActive reports whether the session user may continue.
// checked=false means the active flag could not be verified (unit tests without a DB pool).
// sessionVersion must match users.session_version (bumped on password reset).
func (s *Server) sessionUserIsActive(ctx context.Context, userID uuid.UUID, sessionVersion int) (active bool, checked bool, err error) {
if s != nil && s.testUserSessionState != nil {
st, err := s.testUserSessionState(ctx, userID)
if err != nil {
return false, true, err
}
if !st.Active || st.Version != sessionVersion {
return false, true, nil
}
return true, true, nil
}
if s != nil && s.testUserActive != nil {
ok, err := s.testUserActive(ctx, userID)
return ok, true, err
}
if s == nil || s.Auth == nil || s.Auth.Pool == nil {
return true, false, nil
}
st, err := s.Auth.UserSessionState(ctx, userID)
if err != nil {
return false, true, err
}
if !st.Active || st.Version != sessionVersion {
return false, true, nil
}
return true, true, nil
}
func (s *Server) RequireCompany(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
cidStr := s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey)
if cidStr == "" {
Error(w, http.StatusBadRequest, "company not selected")
return
}
cid, err := uuid.Parse(cidStr)
if err != nil {
Error(w, http.StatusBadRequest, "invalid company")
return
}
m, err := s.Auth.EnsureMembership(r.Context(), uid, cid)
if err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
ctx := context.WithValue(r.Context(), ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, m.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *Server) CSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Public API-key and token export routes do not use cookie CSRF.
// Match path segments only (/api/v1, /api/v1/...) — not prefixes like /api/v10.
if csrfExemptPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
cookie, err := r.Cookie(s.Config.CSRFCookieName)
token := ""
if err == nil {
token = cookie.Value
}
if token == "" {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
Error(w, http.StatusInternalServerError, "csrf token unavailable")
return
}
token = hex.EncodeToString(b)
http.SetCookie(w, &http.Cookie{
Name: s.Config.CSRFCookieName,
Value: token,
Path: "/",
HttpOnly: false, // readable by SPA for X-CSRF-Token double-submit
Secure: s.Config.CookieSecure(),
SameSite: http.SameSiteLaxMode,
MaxAge: 7 * 24 * 60 * 60,
})
}
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
header := r.Header.Get("X-CSRF-Token")
if header == "" || subtle.ConstantTimeCompare([]byte(header), []byte(token)) != 1 {
Error(w, http.StatusForbidden, "csrf token mismatch")
return
}
next.ServeHTTP(w, r)
})
}
// csrfExemptPath is true for public API-key / token / webhook surfaces that
// authenticate without cookie CSRF (Bearer/HMAC/signature).
func csrfExemptPath(path string) bool {
switch {
case path == "/api/v1", strings.HasPrefix(path, "/api/v1/"):
return true
case path == "/api/public", strings.HasPrefix(path, "/api/public/"):
return true
case path == "/api/webhooks", strings.HasPrefix(path, "/api/webhooks/"):
return true
default:
return false
}
}
// extractAPIKey reads the raw key from Authorization Bearer or X-API-Key.
// Preference matches legacy Descrybe: Bearer first, then X-API-Key / X-Api-Key
// (Go canonicalizes header names; both spellings resolve).
func extractAPIKey(r *http.Request) string {
authz := strings.TrimSpace(r.Header.Get("Authorization"))
if authz != "" {
const bearer = "Bearer "
if len(authz) > len(bearer) && strings.EqualFold(authz[:len(bearer)], bearer) {
if key := strings.TrimSpace(authz[len(bearer):]); key != "" {
return key
}
}
}
if k := strings.TrimSpace(r.Header.Get("X-API-Key")); k != "" {
return k
}
return ""
}
// RequireAPIKey authenticates via Bearer or X-API-Key and binds company/user context.
func (s *Server) RequireAPIKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := extractAPIKey(r)
if raw == "" {
CodedError(w, http.StatusUnauthorized, "unauthorized", "Unauthorized")
return
}
id, err := s.Auth.AuthenticateAPIKey(r.Context(), raw)
if err != nil {
if errors.Is(err, auth.ErrInvalidAPIKey) {
CodedError(w, http.StatusUnauthorized, "unauthorized", "Unauthorized")
return
}
CodedError(w, http.StatusInternalServerError, "auth_failed", "Authentication failed")
return
}
ctx := context.WithValue(r.Context(), ctxUserID, id.UserID)
ctx = context.WithValue(ctx, ctxCompanyID, id.CompanyID)
ctx = context.WithValue(ctx, ctxRole, apiKeyContextRole(id.MembershipRole))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func LoadSession(sm *scs.SessionManager) func(http.Handler) http.Handler {
return sm.LoadAndSave
}
// MaintenanceGate enforces MAINTENANCE_MODE / READ_ONLY_MODE.
// /healthz and /readyz always pass so cutover rehearsal probes keep working.
func (s *Server) MaintenanceGate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {
next.ServeHTTP(w, r)
return
}
if s.Config.MaintenanceMode {
JSON(w, http.StatusServiceUnavailable, map[string]any{
"error": "maintenance", "maintenance": true, "read_only": s.Config.ReadOnlyMode,
})
return
}
if s.Config.ReadOnlyMode {
switch r.Method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
JSON(w, http.StatusServiceUnavailable, map[string]any{
"error": "read_only", "maintenance": false, "read_only": true,
})
return
}
}
next.ServeHTTP(w, r)
})
}
// RequirePlatformAdmin allows full platform staff (admin/developer or legacy
// is_platform_admin with empty staff_role). support_staff is excluded.
func (s *Server) RequirePlatformAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
access, err := s.checkStaffAccess(r.Context(), uid)
if err != nil || !access.FullAdmin {
Error(w, http.StatusForbidden, "platform admin required")
return
}
next.ServeHTTP(w, r.WithContext(withStaffAccess(r.Context(), access)))
})
}
// RequireSupportDesk allows full platform admin OR support_staff.
// Plan/billing/settings mutations must stay on RequirePlatformAdmin.
func (s *Server) RequireSupportDesk(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
access, err := s.checkStaffAccess(r.Context(), uid)
if err != nil || !access.SupportDesk {
Error(w, http.StatusForbidden, "support desk access required")
return
}
next.ServeHTTP(w, r.WithContext(withStaffAccess(r.Context(), access)))
})
}
func withStaffAccess(ctx context.Context, access auth.StaffAccess) context.Context {
return context.WithValue(ctx, ctxStaffAccess, access)
}
// StaffAccessFromContext returns capability flags set by RequirePlatformAdmin / RequireSupportDesk.
func StaffAccessFromContext(ctx context.Context) (auth.StaffAccess, bool) {
v, ok := ctx.Value(ctxStaffAccess).(auth.StaffAccess)
return v, ok
}
// checkPlatformAdmin prefers an optional test hook, otherwise Auth.IsPlatformAdmin.
func (s *Server) checkPlatformAdmin(ctx context.Context, userID uuid.UUID) (bool, error) {
if s != nil && s.testPlatformAdmin != nil {
return s.testPlatformAdmin(ctx, userID)
}
if s == nil || s.Auth == nil {
return false, nil
}
return s.Auth.IsPlatformAdmin(ctx, userID)
}
// checkStaffAccess prefers test hooks, otherwise Auth.GetStaffAccess.
func (s *Server) checkStaffAccess(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error) {
if s != nil && s.testStaffAccess != nil {
return s.testStaffAccess(ctx, userID)
}
if s != nil && s.testPlatformAdmin != nil {
ok, err := s.testPlatformAdmin(ctx, userID)
if err != nil {
return auth.StaffAccess{}, err
}
return auth.ResolveStaffAccess(ok, ""), nil
}
if s == nil || s.Auth == nil {
return auth.StaffAccess{}, nil
}
return s.Auth.GetStaffAccess(ctx, userID)
}
@@ -0,0 +1,49 @@
package httpapi
import (
"log/slog"
"net/http"
"time"
chimw "github.com/go-chi/chi/v5/middleware"
)
// statusRecorder captures the response status for structured request logs.
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK
}
n, err := r.ResponseWriter.Write(b)
r.bytes += n
return n, err
}
// RequestLogger emits one structured slog line per request with request_id.
// Pair with chi middleware.RequestID (already mounted in Router).
func RequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
slog.Info("http_request",
"request_id", chimw.GetReqID(r.Context()),
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"duration_ms", time.Since(start).Milliseconds(),
"remote_ip", r.RemoteAddr,
)
})
}
+108
View File
@@ -0,0 +1,108 @@
package httpapi
import (
"net/http"
"strconv"
"strings"
)
const (
defaultPageLimit = 50
maxPageLimit = 200
maxTreePageLimit = 2000
)
// QuerySearch returns the list/search text from query params.
// Accepts both `q` (canonical) and `search` (UI/legacy alias).
func QuerySearch(r *http.Request) string {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q != "" {
return q
}
return strings.TrimSpace(r.URL.Query().Get("search"))
}
// QueryTruthy reports whether a query param is an explicit truthy flag
// (1/true/yes/on). Empty or unrecognized values are false.
func QueryTruthy(r *http.Request, key string) bool {
v := strings.ToLower(strings.TrimSpace(r.URL.Query().Get(key)))
switch v {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// QueryDetailed is true when the client opts into full product list fields
// (JSONB attributes, descriptions, quality scoring inputs) via detailed=1.
func QueryDetailed(r *http.Request) bool {
return QueryTruthy(r, "detailed")
}
// ParseLimitOffset reads limit/offset query params with safe defaults and caps.
// Oversized limits are clamped to maxPageLimit.
func ParseLimitOffset(r *http.Request) (limit, offset int) {
return ParseLimitOffsetMax(r, maxPageLimit)
}
// ParseLimitOffsetMax allows a higher per-endpoint cap and clamps to max
// (used for category tree loads).
func ParseLimitOffsetMax(r *http.Request, max int) (limit, offset int) {
if max <= 0 {
max = maxPageLimit
}
limit, _ = strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ = strconv.Atoi(r.URL.Query().Get("offset"))
if limit <= 0 {
limit = defaultPageLimit
}
if limit > max {
limit = max
}
if offset < 0 {
offset = 0
}
return limit, offset
}
// ParsePageLimitOffset supports legacy page/limit and v2 limit/offset.
// When page is set, offset = (page-1)*limit with legacy defaults (limit=25, max 100).
// When only offset/limit are set (no page), uses ParseLimitOffset defaults (limit=50, max 200).
func ParsePageLimitOffset(r *http.Request) (page, limit, offset int) {
pageRaw := strings.TrimSpace(r.URL.Query().Get("page"))
if pageRaw == "" {
limit, offset = ParseLimitOffset(r)
page = 1
if limit > 0 {
page = offset/limit + 1
}
return page, limit, offset
}
page, _ = strconv.Atoi(pageRaw)
if page < 1 {
page = 1
}
limit, _ = strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 25
}
if limit > 100 {
limit = 100
}
offset = (page - 1) * limit
return page, limit, offset
}
// pageSlice returns a bounded page of items and the original total length.
func pageSlice[T any](items []T, limit, offset int) (page []T, total int) {
total = len(items)
if offset >= total {
return []T{}, total
}
end := offset + limit
if end > total {
end = total
}
return items[offset:end], total
}
@@ -0,0 +1,61 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestQuerySearchPrefersQ(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/api/products?q=alpha&search=beta", nil)
if got := QuerySearch(r); got != "alpha" {
t.Fatalf("got %q want alpha", got)
}
}
func TestQuerySearchFallsBackToSearch(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/api/products?search=%20widget%20", nil)
if got := QuerySearch(r); got != "widget" {
t.Fatalf("got %q want widget", got)
}
}
func TestQuerySearchEmpty(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/api/products", nil)
if got := QuerySearch(r); got != "" {
t.Fatalf("got %q want empty", got)
}
}
func TestQueryTruthy(t *testing.T) {
cases := []struct {
url string
key string
want bool
}{
{"/api/products", "detailed", false},
{"/api/products?detailed=", "detailed", false},
{"/api/products?detailed=0", "detailed", false},
{"/api/products?detailed=false", "detailed", false},
{"/api/products?detailed=1", "detailed", true},
{"/api/products?detailed=true", "detailed", true},
{"/api/products?detailed=YES", "detailed", true},
{"/api/products?detailed=on", "detailed", true},
{"/api/products?detailed=%201%20", "detailed", true},
}
for _, tc := range cases {
r := httptest.NewRequest(http.MethodGet, tc.url, nil)
if got := QueryTruthy(r, tc.key); got != tc.want {
t.Fatalf("%s: got %v want %v", tc.url, got, tc.want)
}
}
}
func TestQueryDetailed(t *testing.T) {
if QueryDetailed(httptest.NewRequest(http.MethodGet, "/api/products?limit=200", nil)) {
t.Fatal("default list must be lean (detailed=false)")
}
if !QueryDetailed(httptest.NewRequest(http.MethodGet, "/api/products?detailed=1&limit=200", nil)) {
t.Fatal("detailed=1 must opt into heavy fields")
}
}
@@ -0,0 +1,94 @@
package httpapi
import (
"errors"
"log"
"net/http"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
)
const (
forgotPasswordIPPerMin = 10
forgotPasswordEmailPerHour = 3
)
func (s *Server) ensureForgotPasswordLimiters() {
s.forgotPasswordOnce.Do(func() {
s.forgotPasswordIPRL = newSlidingWindowLimiter(forgotPasswordIPPerMin, time.Minute)
s.forgotPasswordEmailRL = newSlidingWindowLimiter(forgotPasswordEmailPerHour, time.Hour)
})
}
func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
if s.Mail == nil || s.Auth == nil {
Error(w, http.StatusServiceUnavailable, "mailer unavailable")
return
}
var body struct {
Email string `json:"email"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
email := strings.ToLower(strings.TrimSpace(body.Email))
if email == "" {
Error(w, http.StatusBadRequest, "email is required")
return
}
s.ensureForgotPasswordLimiters()
ipKey := "forgot-password-ip:" + strings.TrimSpace(r.RemoteAddr)
if ipKey == "forgot-password-ip:" {
ipKey = "forgot-password-ip:unknown"
}
emailKey := "forgot-password-email:" + email
if !s.forgotPasswordIPRL.allow(ipKey) || !s.forgotPasswordEmailRL.allow(emailKey) {
w.Header().Set("Retry-After", "60")
Error(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
// Opaque success for unknown / inactive / synthetic / send failures (anti-enumeration).
issue, err := s.Auth.IssuePasswordReset(r.Context(), email, 0)
if err == nil {
msg := mail.ForgotPasswordMessage(s.Config.WebOrigin, issue.Email, issue.Token)
if sendErr := s.Mail.Send(msg); sendErr != nil {
log.Printf("forgot-password send failed")
}
} else if !errors.Is(err, auth.ErrUserNotFound) &&
!errors.Is(err, auth.ErrSyntheticEmail) &&
!errors.Is(err, auth.ErrEmailRequired) {
log.Printf("forgot-password issue failed")
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
if s.Auth == nil {
Error(w, http.StatusServiceUnavailable, "auth unavailable")
return
}
var body struct {
Token string `json:"token"`
Password string `json:"password"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if err := s.Auth.ResetPasswordWithToken(r.Context(), body.Token, body.Password); err != nil {
if errors.Is(err, auth.ErrTokenInvalid) {
Error(w, http.StatusBadRequest, "invalid or expired token")
return
}
ClientOrLog(w, http.StatusBadRequest, "could not reset password", err, auth.ClientError)
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
@@ -0,0 +1,156 @@
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
)
func TestHandleForgotPasswordMailerRequired(t *testing.T) {
t.Parallel()
s := &Server{Auth: &auth.Service{}}
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"email":"a@example.com"}`))
rec := httptest.NewRecorder()
s.handleForgotPassword(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503", rec.Code)
}
}
func TestHandleForgotPasswordRequiresEmail(t *testing.T) {
t.Parallel()
s := &Server{
Mail: &recordingMailer{enabled: true},
Auth: &auth.Service{},
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"email":" "}`))
rec := httptest.NewRecorder()
s.handleForgotPassword(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d want 400 body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleForgotPasswordIPRateLimited(t *testing.T) {
t.Parallel()
s := &Server{
Mail: &recordingMailer{enabled: true},
Auth: &auth.Service{},
}
s.ensureForgotPasswordLimiters()
s.forgotPasswordIPRL = newSlidingWindowLimiter(1, time.Minute)
s.forgotPasswordEmailRL = newSlidingWindowLimiter(10, time.Hour)
key := "forgot-password-ip:203.0.113.50:1"
if !s.forgotPasswordIPRL.allow(key) {
t.Fatal("setup: expected first allow")
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"email":"user@example.com"}`))
req.RemoteAddr = "203.0.113.50:1"
rec := httptest.NewRecorder()
s.handleForgotPassword(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("status=%d want 429 body=%s", rec.Code, rec.Body.String())
}
if rec.Header().Get("Retry-After") == "" {
t.Fatal("expected Retry-After")
}
if strings.Contains(rec.Body.String(), "@") {
t.Fatalf("rate-limit body must not include email: %s", rec.Body.String())
}
}
func TestHandleForgotPasswordEmailRateLimited(t *testing.T) {
t.Parallel()
s := &Server{
Mail: &recordingMailer{enabled: true},
Auth: &auth.Service{},
}
s.ensureForgotPasswordLimiters()
s.forgotPasswordIPRL = newSlidingWindowLimiter(10, time.Minute)
s.forgotPasswordEmailRL = newSlidingWindowLimiter(1, time.Hour)
emailKey := "forgot-password-email:user@example.com"
if !s.forgotPasswordEmailRL.allow(emailKey) {
t.Fatal("setup: expected first allow")
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"email":"User@Example.com"}`))
req.RemoteAddr = "198.51.100.10:9"
rec := httptest.NewRecorder()
s.handleForgotPassword(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("status=%d want 429 body=%s", rec.Code, rec.Body.String())
}
if rec.Header().Get("Retry-After") == "" {
t.Fatal("expected Retry-After")
}
if strings.Contains(rec.Body.String(), "@") {
t.Fatalf("rate-limit body must not include email: %s", rec.Body.String())
}
}
func TestHandleResetPasswordAuthRequired(t *testing.T) {
t.Parallel()
s := &Server{}
req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", bytes.NewBufferString(`{"token":"x","password":"password12"}`))
rec := httptest.NewRecorder()
s.handleResetPassword(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503", rec.Code)
}
}
func TestHandleForgotPasswordSkipsSyntheticEmail(t *testing.T) {
t.Parallel()
mailer := &recordingMailer{enabled: true}
// No Pool: IssuePasswordReset must refuse @legacy.local before any DB access.
s := &Server{
Mail: mailer,
Auth: &auth.Service{},
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
bytes.NewBufferString(`{"email":" Synth_User@Legacy.Local "}`))
req.RemoteAddr = "203.0.113.83:1"
rec := httptest.NewRecorder()
s.handleForgotPassword(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d want 200 body=%s", rec.Code, rec.Body.String())
}
var opaque map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &opaque); err != nil {
t.Fatalf("json: %v", err)
}
if opaque["status"] != "ok" {
t.Fatalf("opaque=%v", opaque)
}
if strings.Contains(rec.Body.String(), "legacy.local") || strings.Contains(rec.Body.String(), "synth") {
t.Fatalf("response must not leak synthetic email: %s", rec.Body.String())
}
if len(mailer.sent) != 0 {
t.Fatalf("synthetic emails must not receive mail, got %d", len(mailer.sent))
}
}
func TestForgotPasswordMessageLink(t *testing.T) {
t.Parallel()
msg := mail.ForgotPasswordMessage("http://localhost:5174/", "a@example.com", "tok123")
if msg.To != "a@example.com" {
t.Fatalf("to=%q", msg.To)
}
if !strings.Contains(msg.Text, "/reset-password#token=tok123") {
t.Fatalf("text missing reset link: %s", msg.Text)
}
if strings.Contains(msg.Text, "/accept-invite") {
t.Fatal("forgot-password mail must not use accept-invite")
}
if msg.Subject != "Reset your Descrybe password" {
t.Fatalf("subject=%q", msg.Subject)
}
}
@@ -0,0 +1,276 @@
package httpapi
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestForgotPasswordResetIntegration(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx := t.Context()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("postgres: %v", err)
}
t.Cleanup(pg.Close)
var tableReady bool
if err := pg.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'password_reset_tokens'
)`).Scan(&tableReady); err != nil {
t.Fatalf("schema probe: %v", err)
}
if !tableReady {
t.Skip("password_reset_tokens missing — run goose up for 041_password_reset_tokens")
}
userID := uuid.New()
prefix := userID.String()[:8]
email := fmt.Sprintf("forgot-reset-%s@example.test", prefix)
oldPassword := "OldPassword123!"
newPassword := "NewPassword456!"
hash, err := auth.HashPassword(oldPassword)
if err != nil {
t.Fatalf("hash: %v", err)
}
_, err = pg.Exec(ctx, `
INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active)
VALUES ($1, $2, $3, $4, false, false, true)`,
userID, email, "Forgot Reset", hash)
if err != nil {
t.Fatalf("seed user: %v", err)
}
t.Cleanup(func() {
cleanupCtx := t.Context()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM password_reset_tokens WHERE user_id = $1`, userID)
_, _ = pg.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, userID)
})
mailer := &recordingMailer{enabled: false}
authSvc := &auth.Service{Pool: pg}
s := &Server{
Config: config.Config{WebOrigin: "http://localhost:5174"},
Mail: mailer,
Auth: authSvc,
}
// Unknown email — opaque 200, no mail.
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
bytes.NewBufferString(`{"email":"missing-`+prefix+`@example.test"}`))
req.RemoteAddr = "203.0.113.80:1"
rec := httptest.NewRecorder()
s.handleForgotPassword(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unknown email status=%d body=%s", rec.Code, rec.Body.String())
}
if len(mailer.sent) != 0 {
t.Fatalf("expected no mail for unknown email, got %d", len(mailer.sent))
}
// Known email — opaque 200 + mail (noop mailer still records Send).
req = httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
bytes.NewBufferString(fmt.Sprintf(`{"email":%q}`, email)))
req.RemoteAddr = "203.0.113.81:1"
rec = httptest.NewRecorder()
s.handleForgotPassword(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("known email status=%d body=%s", rec.Code, rec.Body.String())
}
var opaque map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &opaque); err != nil {
t.Fatalf("json: %v", err)
}
if opaque["status"] != "ok" {
t.Fatalf("opaque=%v", opaque)
}
if strings.Contains(rec.Body.String(), email) || strings.Contains(rec.Body.String(), "token") {
t.Fatalf("response must not leak email/token: %s", rec.Body.String())
}
if len(mailer.sent) != 1 {
t.Fatalf("expected 1 mail, got %d", len(mailer.sent))
}
token := extractResetTokenFromMail(mailer.sent[0].Text)
if token == "" {
t.Fatalf("could not extract token from mail text: %s", mailer.sent[0].Text)
}
var storedHash string
if err := pg.QueryRow(ctx, `
SELECT token_hash FROM password_reset_tokens
WHERE user_id = $1 AND consumed_at IS NULL
ORDER BY created_at DESC LIMIT 1`, userID).Scan(&storedHash); err != nil {
t.Fatalf("load token_hash: %v", err)
}
if storedHash == token {
t.Fatal("DB must store hash only, not plaintext token")
}
if storedHash != auth.HashInviteToken(token) {
t.Fatalf("token_hash=%q want sha256 of raw token", storedHash)
}
if len(storedHash) != 64 {
t.Fatalf("token_hash len=%d want 64", len(storedHash))
}
// Reset succeeds.
req = httptest.NewRequest(http.MethodPost, "/api/auth/reset-password",
bytes.NewBufferString(fmt.Sprintf(`{"token":%q,"password":%q}`, token, newPassword)))
rec = httptest.NewRecorder()
s.handleResetPassword(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("reset status=%d body=%s", rec.Code, rec.Body.String())
}
var sessionVersion int
err = pg.QueryRow(ctx, `SELECT session_version FROM users WHERE id = $1`, userID).Scan(&sessionVersion)
if err != nil {
t.Logf("session_version after reset unavailable (apply 042_user_session_version): %v", err)
} else if sessionVersion != 1 {
t.Fatalf("session_version=%d want 1 after password reset", sessionVersion)
}
// Token reuse fails.
req = httptest.NewRequest(http.MethodPost, "/api/auth/reset-password",
bytes.NewBufferString(fmt.Sprintf(`{"token":%q,"password":%q}`, token, "AnotherPass789!")))
rec = httptest.NewRecorder()
s.handleResetPassword(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("reuse status=%d want 400 body=%s", rec.Code, rec.Body.String())
}
login, err := authSvc.Login(ctx, email, newPassword)
if err != nil {
t.Fatalf("login with new password: %v", err)
}
if login.User.ID != userID {
t.Fatalf("login user=%s want %s", login.User.ID, userID)
}
if _, err := authSvc.Login(ctx, email, oldPassword); err == nil {
t.Fatal("expected old password to fail")
}
}
func TestForgotPasswordSkipsSyntheticEmail(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx := t.Context()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("postgres: %v", err)
}
t.Cleanup(pg.Close)
var tableReady bool
if err := pg.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'password_reset_tokens'
)`).Scan(&tableReady); err != nil || !tableReady {
t.Skip("password_reset_tokens missing — run goose up for 041_password_reset_tokens")
}
userID := uuid.New()
email := fmt.Sprintf("synth-%s@legacy.local", userID.String()[:8])
hash, err := auth.HashPassword("Password123!")
if err != nil {
t.Fatalf("hash: %v", err)
}
_, err = pg.Exec(ctx, `
INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active)
VALUES ($1, $2, $3, $4, false, false, true)`,
userID, email, "Synthetic", hash)
if err != nil {
t.Fatalf("seed: %v", err)
}
t.Cleanup(func() {
cleanupCtx := t.Context()
_, _ = pg.Exec(cleanupCtx, `DELETE FROM password_reset_tokens WHERE user_id = $1`, userID)
_, _ = pg.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, userID)
})
mailer := &recordingMailer{enabled: true}
authSvc := &auth.Service{Pool: pg}
s := &Server{
Config: config.Config{WebOrigin: "http://localhost:5174"},
Mail: mailer,
Auth: authSvc,
}
// Mixed case / whitespace must still be refused (anti-enumeration opaque 200).
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
bytes.NewBufferString(fmt.Sprintf(`{"email":%q}`, " "+strings.ToUpper(email)+" ")))
req.RemoteAddr = "203.0.113.82:1"
rec := httptest.NewRecorder()
s.handleForgotPassword(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var opaque map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &opaque); err != nil {
t.Fatalf("json: %v", err)
}
if opaque["status"] != "ok" {
t.Fatalf("opaque=%v", opaque)
}
if strings.Contains(rec.Body.String(), "legacy.local") || strings.Contains(rec.Body.String(), email) {
t.Fatalf("response must not leak synthetic email: %s", rec.Body.String())
}
if len(mailer.sent) != 0 {
t.Fatalf("synthetic emails must not receive mail, got %d", len(mailer.sent))
}
var tokenCount int
if err := pg.QueryRow(ctx, `
SELECT count(*) FROM password_reset_tokens WHERE user_id = $1`, userID).Scan(&tokenCount); err != nil {
t.Fatalf("token count: %v", err)
}
if tokenCount != 0 {
t.Fatalf("expected 0 reset tokens for synthetic user, got %d", tokenCount)
}
_, err = authSvc.IssuePasswordReset(ctx, email, 0)
if !errors.Is(err, auth.ErrSyntheticEmail) {
t.Fatalf("IssuePasswordReset err=%v want ErrSyntheticEmail", err)
}
}
func extractResetTokenFromMail(text string) string {
const marker = "/reset-password#token="
i := strings.Index(text, marker)
if i < 0 {
// Legacy query-string links (pre-fragment).
const legacy = "/reset-password?token="
i = strings.Index(text, legacy)
if i < 0 {
return ""
}
rest := text[i+len(legacy):]
end := strings.IndexAny(rest, "\r\n \t")
if end < 0 {
return strings.TrimSpace(rest)
}
return strings.TrimSpace(rest[:end])
}
rest := text[i+len(marker):]
end := strings.IndexAny(rest, "\r\n \t")
if end < 0 {
return strings.TrimSpace(rest)
}
return strings.TrimSpace(rest[:end])
}
@@ -0,0 +1,209 @@
package httpapi
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// GET /api/billing/capabilities — effective plan ∩ global features for the active company.
func (s *Server) handleGetCapabilities(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
cid, ok := CompanyIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "company required")
return
}
caps, err := s.Billing.CapabilitiesForCompany(r.Context(), cid)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to load capabilities")
return
}
etag := billing.CapabilitiesResponseETag(caps)
// Private: company-scoped. Short max-age + ETag mirrors OpenAPI conditional GET pattern.
w.Header().Set("Cache-Control", "private, max-age=30, must-revalidate")
w.Header().Set("ETag", etag)
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
JSON(w, http.StatusOK, caps)
}
// GET /api/admin/plans/{planID}/features
func (s *Server) handleAdminGetPlanFeatures(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
planID, err := strconv.ParseInt(strings.TrimSpace(chi.URLParam(r, "planID")), 10, 64)
if err != nil || planID <= 0 {
Error(w, http.StatusBadRequest, "invalid plan id")
return
}
view, err := s.Billing.GetPlanFeatures(r.Context(), planID)
if err != nil {
writePlanFeaturesErr(w, "could not load plan features", err)
return
}
w.Header().Set("Cache-Control", "private, no-store")
JSON(w, http.StatusOK, view)
}
// PUT /api/admin/plans/{planID}/features — replaces stored feature overrides.
func (s *Server) handleAdminPutPlanFeatures(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
planID, err := strconv.ParseInt(strings.TrimSpace(chi.URLParam(r, "planID")), 10, 64)
if err != nil || planID <= 0 {
Error(w, http.StatusBadRequest, "invalid plan id")
return
}
var body billing.PlanFeaturesUpdate
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if body.Features == nil {
Error(w, http.StatusBadRequest, "features required")
return
}
view, err := s.Billing.SetPlanFeatures(r.Context(), planID, body.Features)
if err != nil {
writePlanFeaturesErr(w, "could not save plan features", err)
return
}
JSON(w, http.StatusOK, view)
}
// POST /api/admin/plans/{planID}/features/enable-all — sets every registry key true (custom packages).
func (s *Server) handleAdminEnableAllPlanFeatures(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
planID, err := strconv.ParseInt(strings.TrimSpace(chi.URLParam(r, "planID")), 10, 64)
if err != nil || planID <= 0 {
Error(w, http.StatusBadRequest, "invalid plan id")
return
}
view, err := s.Billing.EnableAllPlanFeatures(r.Context(), planID)
if err != nil {
writePlanFeaturesErr(w, "could not enable plan features", err)
return
}
JSON(w, http.StatusOK, view)
}
// POST /api/admin/plans/{planID}/features/disable-all — sets every registry key false.
func (s *Server) handleAdminDisableAllPlanFeatures(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
planID, err := strconv.ParseInt(strings.TrimSpace(chi.URLParam(r, "planID")), 10, 64)
if err != nil || planID <= 0 {
Error(w, http.StatusBadRequest, "invalid plan id")
return
}
view, err := s.Billing.DisableAllPlanFeatures(r.Context(), planID)
if err != nil {
writePlanFeaturesErr(w, "could not disable plan features", err)
return
}
JSON(w, http.StatusOK, view)
}
// GET /api/admin/feature-gates
func (s *Server) handleAdminGetFeatureGates(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
view, err := s.Billing.GetFeatureGates(r.Context())
if err != nil {
Error(w, http.StatusInternalServerError, "failed to load feature gates")
return
}
w.Header().Set("Cache-Control", "private, no-store")
JSON(w, http.StatusOK, view)
}
// PUT /api/admin/feature-gates — partial upsert of section/feature master switches.
func (s *Server) handleAdminPutFeatureGates(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
var body billing.FeatureGatesUpdate
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if body.Sections == nil && body.Features == nil {
Error(w, http.StatusBadRequest, "sections or features required")
return
}
var updatedBy *uuid.UUID
if uid, ok := UserIDFromContext(r.Context()); ok {
updatedBy = &uid
}
view, err := s.Billing.SetFeatureGates(r.Context(), body.Sections, body.Features, updatedBy)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update feature gates", err, billing.ClientError)
return
}
JSON(w, http.StatusOK, view)
}
// PUT /api/admin/feature-gates/sections/{section} — enable/disable a section for ALL plans.
func (s *Server) handleAdminPutFeatureGateSection(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
section := strings.TrimSpace(chi.URLParam(r, "section"))
if section == "" {
Error(w, http.StatusBadRequest, "section required")
return
}
var body billing.SectionGateUpdate
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if body.Enabled == nil {
Error(w, http.StatusBadRequest, "enabled required")
return
}
var updatedBy *uuid.UUID
if uid, ok := UserIDFromContext(r.Context()); ok {
updatedBy = &uid
}
view, err := s.Billing.SetSectionGate(r.Context(), section, *body.Enabled, updatedBy)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update section gate", err, billing.ClientError)
return
}
JSON(w, http.StatusOK, view)
}
// writePlanFeaturesErr maps known billing client errors to the correct status
// (404 for missing plan; 400 for validation).
func writePlanFeaturesErr(w http.ResponseWriter, publicFallback string, err error) {
if errors.Is(err, billing.ErrPlanNotFound) {
Error(w, http.StatusNotFound, billing.ErrPlanNotFound.Error())
return
}
ClientOrLog(w, http.StatusBadRequest, publicFallback, err, billing.ClientError)
}
@@ -0,0 +1,128 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/google/uuid"
)
func TestRouterPlanFeaturesMounted(t *testing.T) {
t.Parallel()
sm := scs.New()
sm.Cookie.Name = "descrybe_session"
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{
Config: config.Config{
CSRFCookieName: "descrybe_csrf",
WebOrigin: "http://localhost:5173",
},
Sessions: sm,
Auth: &auth.Service{},
Billing: &billing.Service{},
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
return got == uid, nil
},
}
var token string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
token = c.Value
}
}
if token == "" {
t.Fatal("expected session cookie from seed request")
}
h := s.Router()
unauth := httptest.NewRecorder()
h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/admin/feature-gates", nil))
if unauth.Code != http.StatusUnauthorized {
t.Fatalf("unauth status=%d want 401 body=%s", unauth.Code, unauth.Body.String())
}
adminPaths := []string{
"/api/admin/plans/1/features",
"/api/admin/feature-gates",
}
for _, path := range adminPaths {
mounted := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
h.ServeHTTP(mounted, req)
if mounted.Code == http.StatusNotFound {
t.Fatalf("%s not mounted: status=404 body=%s", path, mounted.Body.String())
}
// No DB pool in this unit test — handlers may 500/503/400, but must not 404.
if mounted.Code == http.StatusUnauthorized {
t.Fatalf("%s: unexpected 401 for platform admin session", path)
}
}
bulkPaths := []struct {
method string
path string
}{
{http.MethodPost, "/api/admin/plans/1/features/enable-all"},
{http.MethodPost, "/api/admin/plans/1/features/disable-all"},
{http.MethodPut, "/api/admin/feature-gates/sections/marketing"},
}
for _, tc := range bulkPaths {
mounted := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
h.ServeHTTP(mounted, req)
if mounted.Code == http.StatusNotFound {
t.Fatalf("%s %s not mounted: status=404", tc.method, tc.path)
}
}
// Tenant capabilities require company context — expect 401 without company selection.
caps := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/billing/capabilities", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
h.ServeHTTP(caps, req)
if caps.Code == http.StatusNotFound {
t.Fatalf("capabilities not mounted: status=404")
}
if caps.Code != http.StatusUnauthorized && caps.Code != http.StatusForbidden {
// Company middleware may return 401 or 400 depending on setup; not 404.
if caps.Code == http.StatusOK {
t.Fatalf("capabilities unexpectedly OK without company")
}
}
}
func TestRouterPublicPlansMounted(t *testing.T) {
t.Parallel()
s := &Server{
Config: config.Config{
CSRFCookieName: "descrybe_csrf",
WebOrigin: "http://localhost:5173",
},
Billing: &billing.Service{},
}
h := s.Router()
for _, path := range []string{"/api/public/plans", "/api/public/credit-packs"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code == http.StatusNotFound {
t.Fatalf("%s not mounted: status=404 body=%s", path, rec.Body.String())
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package httpapi
import (
"errors"
"net/http"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
)
// writePlanGate writes a 402 plan_gate payload when err is a known billing gate.
// Returns true when the response was written.
func writePlanGate(w http.ResponseWriter, err error) bool {
if err == nil {
return false
}
if !(errors.Is(err, billing.ErrInsufficientCredits) ||
errors.Is(err, billing.ErrProductLimitExceeded) ||
errors.Is(err, billing.ErrAIRequiresUpgrade) ||
errors.Is(err, billing.ErrEPRELRequiresUpgrade) ||
errors.Is(err, billing.ErrFeatureDisabled)) {
return false
}
body := map[string]any{
"error": err.Error(),
"code": planGateCode(err),
"upgrade_url": "/pricing",
}
if errors.Is(err, billing.ErrFeatureDisabled) {
body["error"] = "feature_disabled"
if key := billing.FeatureKeyFromError(err); key != "" {
body["feature"] = key
}
}
JSON(w, http.StatusPaymentRequired, body)
return true
}
// requireFeatures rejects with 402 when any key is not effective for the company.
// Billing nil: pass-through only outside production; production fails closed with 503.
// Returns false when the response was already written.
func (s *Server) requireFeatures(w http.ResponseWriter, r *http.Request, keys ...string) bool {
if len(keys) == 0 {
return true
}
if s.testAssertFeatures != nil {
if err := s.testAssertFeatures(r.Context(), keys...); err != nil {
if writePlanGate(w, err) {
return false
}
Error(w, http.StatusInternalServerError, "feature check failed")
return false
}
return true
}
if s.Billing == nil {
if s.Config.IsProduction() {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return false
}
return true
}
cid, ok := CompanyIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "company required")
return false
}
if err := s.Billing.AssertFeatures(r.Context(), cid, keys...); err != nil {
if writePlanGate(w, err) {
return false
}
Error(w, http.StatusInternalServerError, "feature check failed")
return false
}
return true
}
// RequireFeature rejects the request with 402 when the company's effective
// features do not include key. Billing nil: pass-through only outside production;
// production fails closed with 503 (never silently allow all features).
func (s *Server) RequireFeature(key string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.requireFeatures(w, r, key) {
return
}
next.ServeHTTP(w, r)
})
}
}
+195
View File
@@ -0,0 +1,195 @@
package httpapi
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/campaigns"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/google/uuid"
)
func TestRequireFeatureBillingNilFailsClosedInProduction(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{AppEnv: "production"}}
h := s.RequireFeature("capability.api_access")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/gated", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
}
}
func TestRequireFeatureBillingNilPassThroughOutsideProduction(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{AppEnv: "development"}}
called := false
h := s.RequireFeature("capability.api_access")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/gated", nil))
if rec.Code != http.StatusNoContent || !called {
t.Fatalf("status=%d called=%v want 204 pass-through", rec.Code, called)
}
}
func TestRequireFeaturesPlanGateViaHook(t *testing.T) {
t.Parallel()
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
s := &Server{
testAssertFeatures: func(_ context.Context, keys ...string) error {
return fmt.Errorf("%w: %s", billing.ErrFeatureDisabled, keys[0])
},
}
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
rec := httptest.NewRecorder()
if s.requireFeatures(rec, req, "marketing.campaigns") {
t.Fatal("requireFeatures should reject disabled feature")
}
if rec.Code != http.StatusPaymentRequired {
t.Fatalf("status=%d want 402 body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "feature_disabled") {
t.Fatalf("body=%s want feature_disabled", rec.Body.String())
}
}
func TestCreateCampaignPlanGate(t *testing.T) {
t.Parallel()
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{
Campaigns: &campaigns.Service{},
testAssertFeatures: func(_ context.Context, keys ...string) error {
return fmt.Errorf("%w: %s", billing.ErrFeatureDisabled, keys[0])
},
}
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "admin")
t.Run("list", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/api/campaigns", nil).WithContext(ctx)
rec := httptest.NewRecorder()
s.handleListCampaigns(rec, req)
if rec.Code != http.StatusPaymentRequired {
t.Fatalf("status=%d want 402 body=%s", rec.Code, rec.Body.String())
}
})
t.Run("create", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodPost, "/api/campaigns", bytes.NewBufferString(`{"name":"x"}`)).WithContext(ctx)
rec := httptest.NewRecorder()
s.handleCreateCampaign(rec, req)
if rec.Code != http.StatusPaymentRequired {
t.Fatalf("status=%d want 402 body=%s", rec.Code, rec.Body.String())
}
})
}
func TestCreateAPIKeyPlanGate(t *testing.T) {
t.Parallel()
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{
testAssertFeatures: func(_ context.Context, keys ...string) error {
return fmt.Errorf("%w: settings.api_keys", billing.ErrFeatureDisabled)
},
}
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "admin")
req := httptest.NewRequest(http.MethodPost, "/api/api-keys", bytes.NewBufferString(`{"name":"x"}`)).WithContext(ctx)
rec := httptest.NewRecorder()
s.handleCreateAPIKey(rec, req)
if rec.Code != http.StatusPaymentRequired {
t.Fatalf("status=%d want 402 body=%s", rec.Code, rec.Body.String())
}
}
func TestCreateAPIKeyBillingNilFailsClosedInProduction(t *testing.T) {
t.Parallel()
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{Config: config.Config{AppEnv: "production"}}
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "admin")
req := httptest.NewRequest(http.MethodPost, "/api/api-keys", bytes.NewBufferString(`{"name":"x"}`)).WithContext(ctx)
rec := httptest.NewRecorder()
s.handleCreateAPIKey(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
}
}
func TestUpdateShopifyConfigPlanGate(t *testing.T) {
t.Parallel()
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{
testAssertFeatures: func(_ context.Context, keys ...string) error {
return fmt.Errorf("%w: stores.shopify", billing.ErrFeatureDisabled)
},
}
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "admin")
req := httptest.NewRequest(http.MethodPut, "/api/shopify", bytes.NewBufferString(`{}`)).WithContext(ctx)
rec := httptest.NewRecorder()
s.handleUpdateShopifyConfig(rec, req)
if rec.Code != http.StatusPaymentRequired {
t.Fatalf("status=%d want 402 body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleListPublicPlansBillingNilReturnsEmpty(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{WebOrigin: "http://localhost:5173"}}
rec := httptest.NewRecorder()
s.handleListPublicPlans(rec, httptest.NewRequest(http.MethodGet, "/api/public/plans", nil))
if rec.Code != http.StatusOK {
t.Fatalf("nil billing status=%d want 200 body=%s", rec.Code, rec.Body.String())
}
s.Billing = &billing.Service{} // non-nil service without pool must not 500
rec2 := httptest.NewRecorder()
s.handleListPublicPlans(rec2, httptest.NewRequest(http.MethodGet, "/api/public/plans", nil))
if rec2.Code != http.StatusOK {
t.Fatalf("empty billing status=%d want 200 body=%s", rec2.Code, rec2.Body.String())
}
}
func TestHandleListPlansBillingNilServiceUnavailable(t *testing.T) {
t.Parallel()
s := &Server{}
rec := httptest.NewRecorder()
s.handleListPlans(rec, httptest.NewRequest(http.MethodGet, "/api/admin/plans", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
}
}
@@ -0,0 +1,98 @@
package httpapi
import (
"errors"
"net/http"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func (s *Server) handleCompleteSetPassword(w http.ResponseWriter, r *http.Request) {
var body struct {
Token string `json:"token"`
Password string `json:"password"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
uid, err := auth.ParseSetPasswordToken(s.Config.TokenSigningSecret, body.Token)
if err != nil {
Error(w, http.StatusBadRequest, "invalid or expired token")
return
}
if sessionEmail, ok := s.sessionUserEmail(r.Context()); ok {
user, gerr := s.Auth.GetUser(r.Context(), uid)
if gerr != nil {
Error(w, http.StatusBadRequest, "invalid or expired token")
return
}
if !auth.EmailsEqual(sessionEmail, user.Email) {
writeEmailMismatch(w, sessionEmail, user.Email)
return
}
}
if err := s.Auth.SetPassword(r.Context(), uid, body.Password); err != nil {
if errors.Is(err, auth.ErrPasswordAlreadySet) {
Error(w, http.StatusBadRequest, "password already set")
return
}
ClientOrLog(w, http.StatusBadRequest, "could not set password", err, auth.ClientError)
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
var body struct {
Name string `json:"name"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
user, err := s.Auth.UpdateProfile(r.Context(), uid, body.Name)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update profile", err, auth.ClientError)
return
}
JSON(w, http.StatusOK, user)
}
func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request) {
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
limit, offset := ParseLimitOffset(r)
page, total, err := s.Auth.ListPendingInvites(r.Context(), cid, limit, offset)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{"invites": page, "total": total, "limit": limit, "offset": offset})
}
func (s *Server) handleRevokeInvite(w http.ResponseWriter, r *http.Request) {
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "inviteID"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.Auth.RevokeInvite(r.Context(), cid, id); err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not revoke invite", err, auth.ClientError)
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
@@ -0,0 +1,183 @@
package httpapi
import (
"errors"
"net/http"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// startProcessingJobRequest is the SPA/API body for POST /processing/jobs.
// ProcessingTypes is accepted because the dashboard sends fine-grained types
// alongside the coarse ProcessingType used by StartJob; DecodeJSON rejects unknowns.
type startProcessingJobRequest struct {
RawProductIDs []string `json:"raw_product_ids"`
ProcessingType string `json:"processing_type"`
ProcessingTypes []string `json:"processing_types"`
}
func (s *Server) handleStartProcessingJob(w http.ResponseWriter, r *http.Request) {
cid, ok := CompanyIDFromContext(r.Context())
if !ok || cid == uuid.Nil {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
uid, _ := UserIDFromContext(r.Context())
var body startProcessingJobRequest
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
ids := make([]uuid.UUID, 0, len(body.RawProductIDs))
for _, sID := range body.RawProductIDs {
id, err := uuid.Parse(sID)
if err != nil {
Error(w, http.StatusBadRequest, "invalid raw_product_id")
return
}
ids = append(ids, id)
}
jobs, err := s.Processing.StartJob(r.Context(), cid, uid, ids, body.ProcessingType)
if err != nil {
if writePlanGate(w, err) {
return
}
if errors.Is(err, processing.ErrRateLimited) {
Error(w, http.StatusTooManyRequests, err.Error())
return
}
if msg, ok := processing.ClientError(err); ok {
Error(w, http.StatusBadRequest, msg)
return
}
LogAndError(w, http.StatusBadRequest, "could not start processing job", err)
return
}
for _, job := range jobs {
if err := s.Jobs.EnqueueProcessingJob(r.Context(), job.ID); err != nil {
Error(w, http.StatusInternalServerError, "enqueue failed")
return
}
}
JSON(w, http.StatusAccepted, processing.FormatStartJobsResponse(jobs))
}
func planGateCode(err error) string {
switch {
case errors.Is(err, billing.ErrInsufficientCredits):
return "insufficient_credits"
case errors.Is(err, billing.ErrProductLimitExceeded):
return "product_limit"
case errors.Is(err, billing.ErrAIRequiresUpgrade):
return "ai_requires_upgrade"
case errors.Is(err, billing.ErrEPRELRequiresUpgrade):
return "eprel_requires_upgrade"
case errors.Is(err, billing.ErrFeatureDisabled):
return "plan_gate"
default:
return "plan_gate"
}
}
func (s *Server) handleListProcessingJobs(w http.ResponseWriter, r *http.Request) {
cid, ok := CompanyIDFromContext(r.Context())
if !ok || cid == uuid.Nil {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
limit, _ := ParseLimitOffset(r)
items, err := s.Processing.ListJobs(r.Context(), cid, limit)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{"jobs": processing.FormatListJobsResponse(items), "limit": limit})
}
func (s *Server) handleGetProcessingJob(w http.ResponseWriter, r *http.Request) {
cid, ok := CompanyIDFromContext(r.Context())
if !ok || cid == uuid.Nil {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
job, err := s.getV1ProcessJob(r.Context(), cid, id)
if err != nil {
Error(w, http.StatusNotFound, "not found")
return
}
if processing.JobStatusIncludesProducts(job.Status) {
items, loadErr := s.loadV1ProcessJobItems(r.Context(), cid, id, job.ProcessingType)
if loadErr != nil {
Error(w, http.StatusInternalServerError, "load failed")
return
}
JSON(w, http.StatusOK, processing.FormatJobStatusResponse(job, items, true))
return
}
JSON(w, http.StatusOK, job)
}
func (s *Server) handleCancelProcessingJob(w http.ResponseWriter, r *http.Request) {
cid, ok := CompanyIDFromContext(r.Context())
if !ok || cid == uuid.Nil {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
job, err := s.Processing.CancelJob(r.Context(), cid, id)
if err != nil {
if msg, ok := processing.ClientError(err); ok {
Error(w, http.StatusBadRequest, msg)
return
}
LogAndError(w, http.StatusBadRequest, "could not cancel job", err)
return
}
JSON(w, http.StatusOK, job)
}
func (s *Server) handleRetryProcessingJob(w http.ResponseWriter, r *http.Request) {
cid, ok := CompanyIDFromContext(r.Context())
if !ok || cid == uuid.Nil {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
job, err := s.Processing.RetryJob(r.Context(), cid, id)
if err != nil {
if writePlanGate(w, err) {
return
}
if errors.Is(err, processing.ErrRateLimited) {
Error(w, http.StatusTooManyRequests, err.Error())
return
}
if msg, ok := processing.ClientError(err); ok {
Error(w, http.StatusBadRequest, msg)
return
}
LogAndError(w, http.StatusBadRequest, "could not retry job", err)
return
}
if err := s.Jobs.EnqueueProcessingJob(r.Context(), job.ID); err != nil {
Error(w, http.StatusInternalServerError, "enqueue failed")
return
}
JSON(w, http.StatusAccepted, job)
}
@@ -0,0 +1,65 @@
package httpapi
import (
"testing"
)
func TestAttachProductQualityStripsHeavyFields(t *testing.T) {
items := []map[string]any{
{
"id": "p1",
"name": "Widget",
"processed_name": "Great Widget",
"category": "Widgets",
"description": "raw description long enough for scoring checks",
"processed_description": "A detailed product description that is long enough.",
"meta_title": "Great Widget | Shop",
"meta_description": "Buy Great Widget with free shipping and a two-year warranty today.",
"attributes": map[string]any{"color": "red"},
"processed_attributes": map[string]any{"color": "red"},
"mapped_data": map[string]any{"image": "https://example.com/w.jpg"},
},
}
attachProductQuality(items)
row := items[0]
if _, ok := row["quality_score"]; !ok {
t.Fatal("expected quality_score")
}
if _, ok := row["quality_grade"]; !ok {
t.Fatal("expected quality_grade")
}
for _, heavy := range []string{
"mapped_data", "attributes", "processed_attributes",
"description", "processed_description", "meta_title", "meta_description",
} {
if _, ok := row[heavy]; ok {
t.Fatalf("heavy field %q should be stripped from detailed list payload", heavy)
}
}
if got := asMapString(row["processed_name"]); got != "Great Widget" {
t.Fatalf("processed_name should remain for display, got %q", got)
}
}
func TestLeanListFieldSetExcludesHeavyJSON(t *testing.T) {
// Contract for default (lean) product list columns — keep in sync with
// catalog.ListProcessedProducts SELECT / scanMaps keys.
lean := map[string]struct{}{
"id": {}, "product_id": {}, "name": {}, "processed_name": {}, "category": {},
"category_name": {}, "category_unique_id": {},
"status": {}, "raw_product_id": {}, "feed_id": {}, "gtin": {},
"feed_name": {}, "feed_last_synced_at": {}, "raw_updated_at": {},
"has_name": {}, "has_processed_name": {}, "has_description": {}, "has_processed_description": {},
"has_category": {}, "has_attributes": {}, "has_processed_attributes": {},
"has_eprel": {},
"created_at": {}, "updated_at": {},
}
for _, heavy := range []string{
"attributes", "processed_attributes", "mapped_data",
"description", "processed_description", "meta_title", "meta_description",
} {
if _, ok := lean[heavy]; ok {
t.Fatalf("lean field set must not include %q", heavy)
}
}
}
@@ -0,0 +1,38 @@
package httpapi
import (
"net/http"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/google/uuid"
)
func (s *Server) handleResetProducts(w http.ResponseWriter, r *http.Request) {
if !requireCompanyAdmin(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
var body struct {
ProductIDs []string `json:"product_ids"`
Kind string `json:"kind"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
ids := make([]uuid.UUID, 0, len(body.ProductIDs))
for _, raw := range body.ProductIDs {
id, err := uuid.Parse(raw)
if err != nil {
Error(w, http.StatusBadRequest, "invalid product_ids")
return
}
ids = append(ids, id)
}
result, err := s.Catalog.ResetProductsToUnprocessed(r.Context(), cid, ids, body.Kind)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not reset products", err, catalog.ClientError)
return
}
JSON(w, http.StatusOK, result)
}
@@ -0,0 +1,207 @@
package httpapi
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/marketing"
)
const (
legacyDefaultPageLimit = 25
legacyMaxPageLimit = 100
)
// ParsePageLimit reads legacy public-API pagination: page (1-based) + limit.
// Defaults match legacy parsePagination: page=1, limit=25, max=100.
// When page is absent but offset is present, offset is honored for compatibility.
func ParsePageLimit(r *http.Request) (page, limit, offset int) {
limit, _ = strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = legacyDefaultPageLimit
}
if limit > legacyMaxPageLimit {
limit = legacyMaxPageLimit
}
page, _ = strconv.Atoi(r.URL.Query().Get("page"))
if page > 0 {
offset = (page - 1) * limit
return page, limit, offset
}
offset, _ = strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
page = offset/limit + 1
return page, limit, offset
}
func v1ProductListMeta(page, limit int, total int64) map[string]any {
totalPages := 0
if limit > 0 {
totalPages = int((total + int64(limit) - 1) / int64(limit))
}
return map[string]any{
"page": page,
"limit": limit,
"total": total,
"totalPages": totalPages,
}
}
func v1ProductStatus(raw string) string {
s := strings.TrimSpace(raw)
if s == "" || strings.EqualFold(s, "all") {
return ""
}
return s
}
func presentV1Product(item map[string]any) map[string]any {
q := marketing.ScoreFromProductMap(item)
name := firstNonEmpty(asMapString(item["name"]), asMapString(item["processed_name"]))
var nameVal any = name
if name == "" {
nameVal = nil
}
category := asMapString(item["category"])
var categoryVal any = category
if category == "" {
categoryVal = nil
}
return map[string]any{
"id": item["id"],
"product_id": item["product_id"],
"name": nameVal,
"category": categoryVal,
"status": item["status"],
"feed_id": nullIfEmptyAny(item["feed_id"]),
"quality_score": q.Score,
"quality_grade": q.Grade,
"created_at": formatV1Timestamp(item["created_at"]),
"updated_at": formatV1Timestamp(item["updated_at"]),
}
}
func nullIfEmptyAny(v any) any {
if v == nil {
return nil
}
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
return nil
}
return v
}
func formatV1Timestamp(v any) any {
switch t := v.(type) {
case nil:
return nil
case time.Time:
if t.IsZero() {
return nil
}
return t.UTC().Format(time.RFC3339)
case string:
if strings.TrimSpace(t) == "" {
return nil
}
return t
default:
return v
}
}
// handleV1ListProducts serves GET /api/v1/products with the legacy public contract:
// { data: presentProduct[], meta: { page, limit, total, totalPages } }.
func (s *Server) handleV1ListProducts(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
page, limit, offset := ParsePageLimit(r)
status := v1ProductStatus(r.URL.Query().Get("status"))
f := catalog.ListFilter{
Query: QuerySearch(r),
Status: status,
FeedID: firstNonEmpty(r.URL.Query().Get("feedId"), r.URL.Query().Get("feed_id")),
SortBy: firstNonEmpty(r.URL.Query().Get("sortBy"), r.URL.Query().Get("sort_by"), "updatedAt"),
SortOrder: firstNonEmpty(r.URL.Query().Get("sortOrder"), r.URL.Query().Get("sort_order"), "desc"),
Limit: limit,
Offset: offset,
}
items, total, err := s.Catalog.ListProcessedProductsDetailed(r.Context(), cid, f)
if err != nil {
if msg, ok := catalog.ClientError(err); ok {
v1Err(w, http.StatusBadRequest, "validation_error", msg)
return
}
v1Err(w, http.StatusInternalServerError, "internal_error", "list failed")
return
}
data := make([]map[string]any, 0, len(items))
for _, item := range items {
data = append(data, presentV1Product(item))
}
v1OK(w, http.StatusOK, data, v1ProductListMeta(page, limit, total))
}
// handleV1ListProductQuality serves GET /api/v1/products/quality with the legacy
// { data, meta } envelope (quality rows + page/limit/total).
func (s *Server) handleV1ListProductQuality(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
page, limit, offset := ParsePageLimit(r)
var minScore *int
if raw := r.URL.Query().Get("min_score"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil {
v1Err(w, http.StatusBadRequest, "validation_error", "invalid min_score")
return
}
minScore = &n
}
status := v1ProductStatus(r.URL.Query().Get("status"))
if status == "" {
status = "completed"
}
f := catalog.ListFilter{
Query: QuerySearch(r),
Status: status,
Category: r.URL.Query().Get("category"),
FeedID: firstNonEmpty(r.URL.Query().Get("feedId"), r.URL.Query().Get("feed_id")),
Limit: limit,
Offset: offset,
}
items, total, err := s.Catalog.ListProcessedProductsDetailed(r.Context(), cid, f)
if err != nil {
v1Err(w, http.StatusInternalServerError, "internal_error", "list failed")
return
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
q := marketing.ScoreFromProductMap(item)
if minScore != nil && q.Score < *minScore {
continue
}
out = append(out, map[string]any{
"id": item["id"],
"product_id": item["product_id"],
"name": firstNonEmpty(asMapString(item["processed_name"]), asMapString(item["name"])),
"quality_score": q.Score,
"quality_grade": q.Grade,
"quality_checks": q.Checks,
})
}
v1OK(w, http.StatusOK, out, map[string]any{
"page": page,
"limit": limit,
"total": total,
})
}
@@ -0,0 +1,109 @@
package httpapi
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestPresentV1ProductFields(t *testing.T) {
ts := time.Date(2026, 8, 1, 10, 15, 0, 0, time.UTC)
row := map[string]any{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"product_id": "SKU-1001",
"name": "Wireless earbuds",
"processed_name": "Acme Wireless Earbuds",
"category": "electronics/audio",
"status": "completed",
"feed_id": "22222222-2222-2222-2222-222222222222",
"description": "raw desc",
"processed_description": "A detailed product description that is long enough for scoring.",
"meta_title": "Acme Wireless Earbuds | Shop",
"meta_description": "Buy Acme Wireless Earbuds with free shipping and a two-year warranty today.",
"attributes": map[string]any{"color": "Black", "brand": "Acme"},
"processed_attributes": map[string]any{"color": "Black", "brand": "Acme"},
"mapped_data": map[string]any{"image": "https://example.com/earbuds.jpg"},
"created_at": ts,
"updated_at": ts,
}
out := presentV1Product(row)
for _, key := range []string{
"id", "product_id", "name", "category", "status", "feed_id",
"quality_score", "quality_grade", "created_at", "updated_at",
} {
if _, ok := out[key]; !ok {
t.Fatalf("missing field %q", key)
}
}
for _, heavy := range []string{
"processed_name", "description", "processed_description",
"attributes", "mapped_data", "gtin", "raw_product_id",
} {
if _, ok := out[heavy]; ok {
t.Fatalf("unexpected heavy field %q in presentProduct payload", heavy)
}
}
if out["name"] != "Wireless earbuds" {
t.Fatalf("name=%v", out["name"])
}
if out["created_at"] != "2026-08-01T10:15:00Z" {
t.Fatalf("created_at=%v", out["created_at"])
}
score, _ := out["quality_score"].(int)
if score <= 0 {
t.Fatalf("expected positive quality_score, got %v", out["quality_score"])
}
}
func TestV1ProductStatusAll(t *testing.T) {
if got := v1ProductStatus("all"); got != "" {
t.Fatalf("all -> %q want empty", got)
}
if got := v1ProductStatus("completed"); got != "completed" {
t.Fatalf("got %q", got)
}
}
func TestV1ProductListMetaJSON(t *testing.T) {
meta := v1ProductListMeta(2, 25, 1284)
b, err := json.Marshal(meta)
if err != nil {
t.Fatal(err)
}
var decoded map[string]any
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatal(err)
}
if decoded["page"].(float64) != 2 || decoded["limit"].(float64) != 25 {
t.Fatalf("meta=%v", decoded)
}
if decoded["total"].(float64) != 1284 {
t.Fatalf("total=%v", decoded["total"])
}
if decoded["totalPages"].(float64) != 52 {
t.Fatalf("totalPages=%v", decoded["totalPages"])
}
}
func TestV1OpenAPIIncludesLegacyProductsEnvelope(t *testing.T) {
body := string(v1OpenAPIYAML)
for _, needle := range []string{
"/products/quality:",
"PresentProduct",
"ProductQualityListResponse",
"quality_score",
"quality_grade",
"name: page",
"name: search",
"name: sortBy",
"name: feedId",
"totalPages",
"LegacyLimit",
"required: [data, meta]",
} {
if !strings.Contains(body, needle) {
t.Fatalf("openapi missing %q", needle)
}
}
}
@@ -0,0 +1,292 @@
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/campaigns"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
emailpkg "github.com/descrybe/descrybe-v2/apps/api/internal/email"
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
"github.com/descrybe/descrybe-v2/apps/api/internal/marketing"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/descrybe/descrybe-v2/apps/api/internal/seo"
"github.com/descrybe/descrybe-v2/apps/api/internal/shopify"
"github.com/descrybe/descrybe-v2/apps/api/internal/woocommerce"
"github.com/jackc/pgx/v5"
)
func TestLogAndErrorHidesInternalDetail(t *testing.T) {
rec := httptest.NewRecorder()
LogAndError(rec, http.StatusInternalServerError, "could not resolve upload", errors.New("open /secret/path: permission denied"))
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status=%d", rec.Code)
}
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["error"] != "could not resolve upload" {
t.Fatalf("error=%q", body["error"])
}
if strings.Contains(rec.Body.String(), "secret") {
t.Fatal("leaked internal path detail")
}
}
func TestClientOrLogPreservesAuthValidation(t *testing.T) {
rec := httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "registration failed", auth.ErrPasswordTooShort, auth.ClientError)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "password must be at least 8 characters") {
t.Fatalf("body=%s", rec.Body.String())
}
}
func TestClientOrLogHidesOpaqueAuthDBError(t *testing.T) {
rec := httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "registration failed", errors.New("ERROR: duplicate key value violates unique constraint \"users_email_key\" (SQLSTATE 23505)"), auth.ClientError)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d", rec.Code)
}
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["error"] != "registration failed" {
t.Fatalf("error=%q", body["error"])
}
if strings.Contains(rec.Body.String(), "SQLSTATE") || strings.Contains(rec.Body.String(), "users_email") {
t.Fatal("leaked DB detail")
}
}
func TestClientOrLogPreservesBillingSentinel(t *testing.T) {
rec := httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "checkout failed", billing.ErrStripePlanUnsupported, billing.ClientError)
if !strings.Contains(rec.Body.String(), "not available for self-serve") {
t.Fatalf("body=%s", rec.Body.String())
}
}
func TestClientOrLogHidesStripeProviderError(t *testing.T) {
rec := httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "checkout failed", errors.New("stripe api 400: {\"error\":{\"message\":\"No such price: price_secret_abc\"}}"), billing.ClientError)
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["error"] != "checkout failed" {
t.Fatalf("error=%q", body["error"])
}
if strings.Contains(rec.Body.String(), "price_secret") || strings.Contains(rec.Body.String(), "No such price") {
t.Fatal("leaked Stripe provider detail")
}
}
func TestCatalogClientErrorPreservesValidation(t *testing.T) {
msg, ok := catalog.ClientError(catalog.ClientMsg("name and unique_id required"))
if !ok || msg != "name and unique_id required" {
t.Fatalf("msg=%q ok=%v", msg, ok)
}
if _, ok := catalog.ClientError(errors.New("pq: relation \"categories\" does not exist")); ok {
t.Fatal("opaque DB error must not be client-facing")
}
}
func TestShopifyWooClientErrorSentinels(t *testing.T) {
if msg, ok := shopify.ClientError(shopify.ErrMissingCreds); !ok || msg == "" {
t.Fatal("shopify missing creds")
}
if _, ok := shopify.ClientError(errors.New("dial tcp 10.0.0.1:443: i/o timeout")); ok {
t.Fatal("shopify opaque must not be client-facing")
}
if msg, ok := woocommerce.ClientError(woocommerce.ErrInvalidStoreURL); !ok || !strings.Contains(msg, "store url") {
t.Fatalf("woo invalid url msg=%q ok=%v", msg, ok)
}
}
func TestWritePublicExportErrorUsesFormatMismatchSentinel(t *testing.T) {
rec := httptest.NewRecorder()
writePublicExportError(rec, feeds.ErrFormatMismatch)
// Must match unknown-token responses so format probes cannot confirm a token.
if rec.Code != http.StatusNotFound {
t.Fatalf("status=%d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "export feed not found") {
t.Fatalf("body=%s", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "format mismatch") {
t.Fatalf("must not leak format mismatch: body=%s", rec.Body.String())
}
rec = httptest.NewRecorder()
writePublicExportError(rec, pgx.ErrNoRows)
if rec.Code != http.StatusNotFound {
t.Fatalf("status=%d", rec.Code)
}
}
func TestFeedsClientErrorPreservesValidation(t *testing.T) {
msg, ok := feeds.ClientError(feeds.ClientMsg("name required"))
if !ok || msg != "name required" {
t.Fatalf("msg=%q ok=%v", msg, ok)
}
if _, ok := feeds.ClientError(errors.New("pq: relation \"input_feeds\" does not exist")); ok {
t.Fatal("opaque DB error must not be client-facing")
}
ClientOrLog(httptest.NewRecorder(), http.StatusBadRequest, "could not create feed", errors.New("dial tcp timeout"), feeds.ClientError)
}
func TestCampaignsClientErrorPreservesSentinel(t *testing.T) {
rec := httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "could not create campaign", campaigns.ErrNameRequired, campaigns.ClientError)
if !strings.Contains(rec.Body.String(), "name required") {
t.Fatalf("body=%s", rec.Body.String())
}
rec = httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "could not create campaign", errors.New("ERROR: duplicate key"), campaigns.ClientError)
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["error"] != "could not create campaign" {
t.Fatalf("error=%q", body["error"])
}
}
func TestEmailClientErrorHidesProviderDetail(t *testing.T) {
rec := httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "could not update email settings", emailpkg.ClientMsg("provider must be resend or smtp"), emailpkg.ClientError)
if !strings.Contains(rec.Body.String(), "provider must be resend or smtp") {
t.Fatalf("body=%s", rec.Body.String())
}
rec = httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "email verification failed", errors.New("resend api 500: internal secret"), emailpkg.ClientError)
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["error"] != "email verification failed" {
t.Fatalf("error=%q", body["error"])
}
if strings.Contains(rec.Body.String(), "secret") {
t.Fatal("leaked provider detail")
}
}
func TestAIProviderClientErrorHidesBaseURLDetail(t *testing.T) {
rec := httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "could not update ai settings", aiprovider.ErrInvalidMode, aiprovider.ClientError)
if !strings.Contains(rec.Body.String(), "mode must be") {
t.Fatalf("body=%s", rec.Body.String())
}
rec = httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "could not update ai settings", errors.New("encrypt: cipher: message authentication failed"), aiprovider.ClientError)
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["error"] != "could not update ai settings" {
t.Fatalf("error=%q", body["error"])
}
}
func TestMarketingClientErrorPreservesPresetValidation(t *testing.T) {
rec := httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "could not prepare campaign", marketing.ClientMsg("preset_id must be black_friday or christmas"), marketing.ClientError)
if !strings.Contains(rec.Body.String(), "preset_id must be") {
t.Fatalf("body=%s", rec.Body.String())
}
}
func TestAuthInviteEmailRequired(t *testing.T) {
rec := httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "could not create invite", auth.ErrEmailRequired, auth.ClientError)
if !strings.Contains(rec.Body.String(), "email is required") {
t.Fatalf("body=%s", rec.Body.String())
}
}
func TestProcessingRateLimitStatusViaSentinel(t *testing.T) {
// Mirror handler status selection without spinning up Server deps.
err := processing.ErrRateLimited
status := http.StatusBadRequest
if errors.Is(err, processing.ErrRateLimited) {
status = http.StatusTooManyRequests
}
if status != http.StatusTooManyRequests {
t.Fatalf("status=%d", status)
}
if strings.Contains(err.Error(), "rate limit") && !errors.Is(err, processing.ErrRateLimited) {
t.Fatal("regression: string matching alone is insufficient")
}
}
func TestSEONotFoundUsesSentinel(t *testing.T) {
if !errors.Is(seo.ErrNotFound, seo.ErrNotFound) {
t.Fatal("seo.ErrNotFound identity broken")
}
opaque := errors.New("product row not found in warehouse")
if errors.Is(opaque, seo.ErrNotFound) {
t.Fatal("opaque message must not match ErrNotFound")
}
}
func TestSEOClientErrorPreservesInvalidMode(t *testing.T) {
rec := httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "seo apply failed", seo.ErrInvalidMode, seo.ClientError)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "mode must be template or ai") {
t.Fatalf("body=%s", rec.Body.String())
}
rec = httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "seo apply failed", errors.New("openai: api key sk-secret leaked"), seo.ClientError)
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["error"] != "seo apply failed" {
t.Fatalf("error=%q", body["error"])
}
if strings.Contains(rec.Body.String(), "sk-secret") {
t.Fatal("leaked opaque SEO apply detail")
}
}
func TestBrandLogoClientErrorPreservesValidation(t *testing.T) {
rec := httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "could not upload logo", company.ErrLogoInvalidType, company.ClientError)
if !strings.Contains(rec.Body.String(), "logo must be PNG, JPEG, or WebP") {
t.Fatalf("body=%s", rec.Body.String())
}
rec = httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "could not upload logo", company.ErrLogoTooLarge, company.ClientError)
if !strings.Contains(rec.Body.String(), "logo exceeds 2 MiB limit") {
t.Fatalf("body=%s", rec.Body.String())
}
rec = httptest.NewRecorder()
ClientOrLog(rec, http.StatusBadRequest, "could not upload logo", errors.New("open /secret/uploads: permission denied"), company.ClientError)
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["error"] != "could not upload logo" {
t.Fatalf("error=%q", body["error"])
}
if strings.Contains(rec.Body.String(), "secret") || strings.Contains(rec.Body.String(), "permission denied") {
t.Fatal("leaked filesystem detail")
}
}
+528
View File
@@ -0,0 +1,528 @@
package httpapi
import (
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// HTTP rate limiters in this file are in-process (per API OS process / replica).
//
// MULTI-REPLICA CUTOVER (see docs/production-readiness.md § edge rate limits):
// - There is no Redis (or other shared store) in the Descrybe stack today.
// - Without edge caps, effective HTTP budget across N replicas is roughly
// N× the per-process base RPM.
// - RATE_LIMIT_REPLICAS=N (optional) divides only the HTTP middleware caps in
// this file via rateLimitEffectiveCap (ceil) so aggregate under even load
// approximates the documented RPM. It is not a shared counter and does not
// affect login email lockout, processing.StartLimiter, support.AIRateLimiter,
// or email send limiters — those stay per-process until edge/shared infra.
// - Cutover for multi-replica hard global RPM: enforce cluster caps at the
// edge (CDN/ingress/WAF). RATE_LIMIT_REPLICAS alone is not a substitute.
// - RATE_LIMIT_MULTI_REPLICA=true acknowledges multi-replica deploy without a
// shared backend; the API logs a boot warning (config.RateLimitWarningMessage).
// - RATE_LIMIT_BACKEND=redis|postgres is accepted as documentation only and
// forced to memory until a shared backend is implemented — do not assume
// distributed counters exist.
// slidingWindowLimiter is a light in-process rate limiter (per-key).
// Suitable for a single API instance; not shared across replicas.
type slidingWindowLimiter struct {
mu sync.Mutex
window time.Duration
limit int
hits map[string][]time.Time
lastGC time.Time
}
func newSlidingWindowLimiter(limit int, window time.Duration) *slidingWindowLimiter {
if limit <= 0 {
limit = 30
}
if window <= 0 {
window = time.Minute
}
return &slidingWindowLimiter{
window: window,
limit: limit,
hits: make(map[string][]time.Time),
lastGC: time.Now(),
}
}
func (l *slidingWindowLimiter) allow(key string) bool {
now := time.Now()
cutoff := now.Add(-l.window)
l.mu.Lock()
defer l.mu.Unlock()
if now.Sub(l.lastGC) > l.window {
for k, ts := range l.hits {
kept := ts[:0]
for _, t := range ts {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) == 0 {
delete(l.hits, k)
} else {
l.hits[k] = kept
}
}
l.lastGC = now
}
ts := l.hits[key]
kept := ts[:0]
for _, t := range ts {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= l.limit {
l.hits[key] = kept
return false
}
l.hits[key] = append(kept, now)
return true
}
// writeRateLimited responds 429 with Retry-After plus IETF RateLimit headers
// (draft-ietf-httpapi-ratelimit-headers) so clients can back off before retrying.
// On deny, remaining is always 0; t/w use the limiter window in seconds.
func writeRateLimited(w http.ResponseWriter, limit, windowSec int) {
if limit < 1 {
limit = 1
}
if windowSec < 1 {
windowSec = 60
}
w.Header().Set("Retry-After", strconv.Itoa(windowSec))
w.Header().Set("RateLimit", fmt.Sprintf(`"http";r=0;t=%d`, windowSec))
w.Header().Set("RateLimit-Policy", fmt.Sprintf(`"http";q=%d;w=%d`, limit, windowSec))
Error(w, http.StatusTooManyRequests, "rate limit exceeded")
}
// rateLimitEffectiveCap divides a per-process HTTP base cap across RATE_LIMIT_REPLICAS
// (ceil) so aggregate traffic under even load approximates the documented RPM.
// replicas<=1 leaves the base unchanged (default single-instance behavior).
// Scope: HTTP middleware in this file only — not lockout / StartLimiter / AI / email.
func rateLimitEffectiveCap(base, replicas int) int {
if base <= 0 {
return 1
}
if replicas <= 1 {
return base
}
n := (base + replicas - 1) / replicas
if n < 1 {
return 1
}
return n
}
func (s *Server) rateLimitReplicas() int {
if s == nil || s.Config.RateLimitReplicas < 1 {
return 1
}
return s.Config.RateLimitReplicas
}
// heavyMutationRPM is the per-company HTTP budget for sync / process / export mutations.
// In-process only (not shared across replicas). Counts requests, not products in a bulk body.
const heavyMutationRPM = 30
func isHeavyFeedOrProcessMutation(r *http.Request) bool {
if r.Method != http.MethodPost {
return false
}
path := strings.TrimSuffix(r.URL.Path, "/")
switch path {
case "/api/v1/process", "/api/v1/products/process", "/api/processing/jobs":
return true
default:
if strings.HasSuffix(path, "/sync-process-sample") || strings.HasSuffix(path, "/extract-schema") {
return true
}
// Export generate / selected-product export — heavy CPU + IO per request.
if strings.Contains(path, "/export-feeds/") &&
(strings.HasSuffix(path, "/generate") || strings.HasSuffix(path, "/export-products")) {
return true
}
// Process job retries also consume StartLimiter capacity.
if strings.HasSuffix(path, "/retry") &&
(strings.Contains(path, "/processing/jobs/") || strings.Contains(path, "/process/")) {
return true
}
// Feed sync downloads/parses remote content — throttle both /api and /api/v1.
// Store connector syncs (/woocommerce/sync, /shopify/…) are intentionally excluded;
// they use connector-specific workers and are not part of this shared bucket.
return strings.HasSuffix(path, "/sync") && strings.Contains(path, "/feeds/")
}
}
// RateLimitV1Process throttles heavy process / feed sync / export mutations per company.
// Limit is in-process (per API replica); set RATE_LIMIT_REPLICAS or prefer edge limits when running multiple replicas.
func (s *Server) RateLimitV1Process(next http.Handler) http.Handler {
cap := rateLimitEffectiveCap(heavyMutationRPM, s.rateLimitReplicas())
limiter := newSlidingWindowLimiter(cap, time.Minute)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isHeavyFeedOrProcessMutation(r) {
next.ServeHTTP(w, r)
return
}
cid, ok := CompanyIDFromContext(r.Context())
key := "anon"
if ok && cid != uuid.Nil {
key = cid.String()
}
if !limiter.allow(key) {
writeRateLimited(w, cap, 60)
return
}
next.ServeHTTP(w, r)
})
}
// publicRPM is the per-IP budget for unauthenticated /api/public routes (plans, logos, …).
const publicRPM = 30
// RateLimitPublic throttles unauthenticated /api/public routes per client IP
// (RemoteAddr; rewritten only via TrustedRealIP when TRUSTED_PROXIES is set).
func (s *Server) RateLimitPublic(next http.Handler) http.Handler {
cap := rateLimitEffectiveCap(publicRPM, s.rateLimitReplicas())
limiter := newSlidingWindowLimiter(cap, time.Minute)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := strings.TrimSpace(r.RemoteAddr)
if key == "" {
key = "unknown"
}
if !limiter.allow(key) {
writeRateLimited(w, cap, 60)
return
}
next.ServeHTTP(w, r)
})
}
// Public export token scraping budgets (in-process; see file header for replicas).
const (
publicExportIPRPM = 30 // well-formed export GETs per IP
publicExportProbeRPM = 15 // invalid-shape token probes per IP (enumeration)
publicExportTokenRPM = 30 // polls per public_token (known-token scrape)
)
// RateLimitPublicExport throttles tokenized export GETs harder than generic /api/public.
// Invalid token shapes are rejected here (no DB) and counted against a probe budget.
func (s *Server) RateLimitPublicExport(next http.Handler) http.Handler {
ipCap := rateLimitEffectiveCap(publicExportIPRPM, s.rateLimitReplicas())
probeCap := rateLimitEffectiveCap(publicExportProbeRPM, s.rateLimitReplicas())
tokenCap := rateLimitEffectiveCap(publicExportTokenRPM, s.rateLimitReplicas())
ipLimiter := newSlidingWindowLimiter(ipCap, time.Minute)
probeLimiter := newSlidingWindowLimiter(probeCap, time.Minute)
tokenLimiter := newSlidingWindowLimiter(tokenCap, time.Minute)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := strings.TrimSpace(r.RemoteAddr)
if ip == "" {
ip = "unknown"
}
token := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "token")))
if !feeds.ValidPublicToken(token) {
if !probeLimiter.allow(ip) {
writeRateLimited(w, probeCap, 60)
return
}
// Same body as writePublicExportError — no oracle for token existence.
Error(w, http.StatusNotFound, "export feed not found")
return
}
if !ipLimiter.allow(ip) {
writeRateLimited(w, ipCap, 60)
return
}
if !tokenLimiter.allow("t:" + token) {
writeRateLimited(w, tokenCap, 60)
return
}
next.ServeHTTP(w, r)
})
}
// API key surface budgets (in-process).
const (
apiKeyAttemptRPM = 60 // keyed /api/v1 requests per IP (brute-force / spray)
apiKeyCompanyRPM = 120 // authenticated /api/v1 requests per company
)
// RateLimitAPIKeyAttempts throttles /api/v1 requests that present an API key, per IP.
// Mount before RequireAPIKey so invalid keys still consume budget.
func (s *Server) RateLimitAPIKeyAttempts(next http.Handler) http.Handler {
cap := rateLimitEffectiveCap(apiKeyAttemptRPM, s.rateLimitReplicas())
limiter := newSlidingWindowLimiter(cap, time.Minute)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if extractAPIKey(r) == "" {
next.ServeHTTP(w, r)
return
}
key := strings.TrimSpace(r.RemoteAddr)
if key == "" {
key = "unknown"
}
if !limiter.allow(key) {
writeRateLimited(w, cap, 60)
return
}
next.ServeHTTP(w, r)
})
}
// RateLimitAPIKey throttles authenticated /api/v1 traffic per company (API4 abuse cap).
func (s *Server) RateLimitAPIKey(next http.Handler) http.Handler {
cap := rateLimitEffectiveCap(apiKeyCompanyRPM, s.rateLimitReplicas())
limiter := newSlidingWindowLimiter(cap, time.Minute)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cid, ok := CompanyIDFromContext(r.Context())
key := "anon"
if ok && cid != uuid.Nil {
key = cid.String()
}
if !limiter.allow(key) {
writeRateLimited(w, cap, 60)
return
}
next.ServeHTTP(w, r)
})
}
func isMarketingGenerateOrSend(r *http.Request) bool {
if r.Method != http.MethodPost {
return false
}
path := strings.TrimSuffix(r.URL.Path, "/")
switch {
case path == "/api/seo/apply":
return true
case path == "/api/campaigns/generate":
return true
case strings.HasSuffix(path, "/generate") && strings.Contains(path, "/campaigns/"):
return true
case path == "/api/campaigns/send" || path == "/api/campaigns/send-test":
return true
case strings.HasSuffix(path, "/send") || strings.HasSuffix(path, "/send-test") || strings.HasSuffix(path, "/schedule"):
return strings.Contains(path, "/campaigns/")
case path == "/api/integrations/email/send" || path == "/api/email/send":
return true
default:
return false
}
}
// RateLimitMarketing throttles campaign generate/send and SEO AI apply per company.
// In-process only (per API replica); set RATE_LIMIT_REPLICAS or prefer edge limits when running multiple replicas.
func (s *Server) RateLimitMarketing(next http.Handler) http.Handler {
genCap := rateLimitEffectiveCap(10, s.rateLimitReplicas())
sendCap := rateLimitEffectiveCap(30, s.rateLimitReplicas())
genLimiter := newSlidingWindowLimiter(genCap, time.Minute)
sendLimiter := newSlidingWindowLimiter(sendCap, time.Minute)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isMarketingGenerateOrSend(r) {
next.ServeHTTP(w, r)
return
}
cid, ok := CompanyIDFromContext(r.Context())
key := "anon"
if ok && cid != uuid.Nil {
key = cid.String()
}
path := strings.TrimSuffix(r.URL.Path, "/")
limiter := sendLimiter
cap := sendCap
if strings.Contains(path, "generate") || path == "/api/seo/apply" {
limiter = genLimiter
cap = genCap
}
if !limiter.allow(key) {
writeRateLimited(w, cap, 60)
return
}
next.ServeHTTP(w, r)
})
}
const (
authLoginRPM = 10 // login / invite / set-password / sales-contact per IP
authRegisterRPM = 5 // registration spam bucket (stricter than login)
)
func isAuthRegister(r *http.Request) bool {
return r.Method == http.MethodPost && strings.TrimSuffix(r.URL.Path, "/") == "/api/auth/register"
}
func isAuthMutation(r *http.Request) bool {
if r.Method != http.MethodPost {
return false
}
switch strings.TrimSuffix(r.URL.Path, "/") {
case "/api/auth/login",
"/api/auth/register",
"/api/auth/forgot-password",
"/api/auth/reset-password",
"/api/auth/invite-preview",
"/api/auth/accept-invite",
"/api/auth/complete-set-password",
"/api/sales/contact":
return true
default:
return false
}
}
// RateLimitAuth throttles unauthenticated auth POSTs per client IP
// (RemoteAddr; rewritten only via TrustedRealIP when TRUSTED_PROXIES is set).
// Register uses a stricter bucket so login brute-force and signup spam do not share budget.
func (s *Server) RateLimitAuth(next http.Handler) http.Handler {
loginCap := rateLimitEffectiveCap(authLoginRPM, s.rateLimitReplicas())
registerCap := rateLimitEffectiveCap(authRegisterRPM, s.rateLimitReplicas())
loginLimiter := newSlidingWindowLimiter(loginCap, time.Minute)
registerLimiter := newSlidingWindowLimiter(registerCap, time.Minute)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isAuthMutation(r) {
next.ServeHTTP(w, r)
return
}
key := strings.TrimSpace(r.RemoteAddr)
if key == "" {
key = "unknown"
}
limiter := loginLimiter
cap := loginCap
if isAuthRegister(r) {
limiter = registerLimiter
cap = registerCap
}
if !limiter.allow(key) {
writeRateLimited(w, cap, 60)
return
}
next.ServeHTTP(w, r)
})
}
// adminPlanFeaturesGetRPM caps GET /api/admin/plans/{id}/features per user.
// In-process only; stops client refetch storms from saturating the API.
const adminPlanFeaturesGetRPM = 60
func isAdminPlanFeaturesGet(r *http.Request) bool {
if r.Method != http.MethodGet {
return false
}
path := strings.TrimSuffix(r.URL.Path, "/")
if !strings.HasPrefix(path, "/api/admin/plans/") {
return false
}
return strings.HasSuffix(path, "/features")
}
// RateLimitAdminPlanFeatures throttles repeated GET plan-feature matrix fetches per user.
func (s *Server) RateLimitAdminPlanFeatures(next http.Handler) http.Handler {
cap := rateLimitEffectiveCap(adminPlanFeaturesGetRPM, s.rateLimitReplicas())
limiter := newSlidingWindowLimiter(cap, time.Minute)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isAdminPlanFeaturesGet(r) {
next.ServeHTTP(w, r)
return
}
uid, ok := UserIDFromContext(r.Context())
key := "anon"
if ok && uid != uuid.Nil {
key = uid.String()
}
if !limiter.allow(key) {
writeRateLimited(w, cap, 60)
return
}
next.ServeHTTP(w, r)
})
}
// adminAnalyticsGetRPM caps expensive admin diagnostic/analytics GETs per user.
const adminAnalyticsGetRPM = 20
func isAdminAnalyticsGet(r *http.Request) bool {
if r.Method != http.MethodGet {
return false
}
path := strings.TrimSuffix(r.URL.Path, "/")
return path == "/api/admin/analytics" || path == "/api/admin/diagnostics"
}
// RateLimitAdminAnalytics throttles expensive platform analytics/diagnostics reads per user.
func (s *Server) RateLimitAdminAnalytics(next http.Handler) http.Handler {
cap := rateLimitEffectiveCap(adminAnalyticsGetRPM, s.rateLimitReplicas())
limiter := newSlidingWindowLimiter(cap, time.Minute)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isAdminAnalyticsGet(r) {
next.ServeHTTP(w, r)
return
}
uid, ok := UserIDFromContext(r.Context())
key := "anon"
if ok && uid != uuid.Nil {
key = uid.String()
}
if !limiter.allow(key) {
writeRateLimited(w, cap, 60)
return
}
next.ServeHTTP(w, r)
})
}
// aiProbeRPM caps LLM/mail credential probes (cost / abuse).
const aiProbeRPM = 10
func isAIOrMailProbePOST(r *http.Request) bool {
if r.Method != http.MethodPost {
return false
}
path := strings.TrimSuffix(r.URL.Path, "/")
switch {
case path == "/api/integrations/ai/test":
return true
case path == "/api/admin/settings/mail/test":
return true
case strings.HasPrefix(path, "/api/admin/settings/ai-roles/") && strings.HasSuffix(path, "/test"):
return true
default:
return false
}
}
// RateLimitAIProbes throttles AI/mail test probes per user (admin) or company (tenant).
func (s *Server) RateLimitAIProbes(next http.Handler) http.Handler {
cap := rateLimitEffectiveCap(aiProbeRPM, s.rateLimitReplicas())
limiter := newSlidingWindowLimiter(cap, time.Minute)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isAIOrMailProbePOST(r) {
next.ServeHTTP(w, r)
return
}
key := "anon"
if uid, ok := UserIDFromContext(r.Context()); ok && uid != uuid.Nil {
key = "u:" + uid.String()
} else if cid, ok := CompanyIDFromContext(r.Context()); ok && cid != uuid.Nil {
key = "c:" + cid.String()
}
if !limiter.allow(key) {
writeRateLimited(w, cap, 60)
return
}
next.ServeHTTP(w, r)
})
}
@@ -0,0 +1,55 @@
package httpapi
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestSlidingWindowLimiterAllowConcurrent(t *testing.T) {
t.Parallel()
l := newSlidingWindowLimiter(15, time.Minute)
var allowed atomic.Int64
var wg sync.WaitGroup
const n = 50
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
if l.allow("company-a") {
allowed.Add(1)
}
}()
}
wg.Wait()
if got := allowed.Load(); got != 15 {
t.Fatalf("allowed=%d want 15", got)
}
}
func TestSlidingWindowLimiterSeparateKeys(t *testing.T) {
t.Parallel()
l := newSlidingWindowLimiter(5, time.Minute)
var a, b atomic.Int64
var wg sync.WaitGroup
wg.Add(20)
for i := 0; i < 10; i++ {
go func() {
defer wg.Done()
if l.allow("a") {
a.Add(1)
}
}()
go func() {
defer wg.Done()
if l.allow("b") {
b.Add(1)
}
}()
}
wg.Wait()
if a.Load() != 5 || b.Load() != 5 {
t.Fatalf("a=%d b=%d want 5 each", a.Load(), b.Load())
}
}
+538
View File
@@ -0,0 +1,538 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func TestRateLimitAuthBlocksBurst(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{}}
h := s.RateLimitAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
var saw429 bool
for i := 0; i < authLoginRPM+5; i++ {
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
req.RemoteAddr = "203.0.113.10:12345"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == http.StatusTooManyRequests {
saw429 = true
break
}
if rec.Code != http.StatusNoContent {
t.Fatalf("unexpected status %d", rec.Code)
}
}
if !saw429 {
t.Fatal("expected 429 after auth burst")
}
}
func TestRateLimitAuthRegisterStricterThanLogin(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{}}
h := s.RateLimitAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
var saw429 bool
for i := 0; i < authRegisterRPM+3; i++ {
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", nil)
req.RemoteAddr = "203.0.113.20:12345"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == http.StatusTooManyRequests {
saw429 = true
break
}
if rec.Code != http.StatusNoContent {
t.Fatalf("unexpected status %d", rec.Code)
}
}
if !saw429 {
t.Fatal("expected 429 after register burst")
}
// Login budget is independent — register exhaust must not block login.
login := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
login.RemoteAddr = "203.0.113.20:12345"
recLogin := httptest.NewRecorder()
h.ServeHTTP(recLogin, login)
if recLogin.Code != http.StatusNoContent {
t.Fatalf("login should use separate bucket, got %d", recLogin.Code)
}
}
func TestRateLimitAuthIncludesSalesContact(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{}}
h := s.RateLimitAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
var saw429 bool
for i := 0; i < authLoginRPM+3; i++ {
req := httptest.NewRequest(http.MethodPost, "/api/sales/contact", nil)
req.RemoteAddr = "203.0.113.21:12345"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == http.StatusTooManyRequests {
saw429 = true
break
}
}
if !saw429 {
t.Fatal("expected 429 after sales contact burst")
}
}
func TestRateLimitAuthSkipsSafeMethods(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{}}
h := s.RateLimitAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
for i := 0; i < 30; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
req.RemoteAddr = "203.0.113.11:12345"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("GET should not be rate-limited, got %d", rec.Code)
}
}
}
func TestRateLimitAdminPlanFeaturesBlocksBurst(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{}}
uid := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
h := s.RateLimitAdminPlanFeatures(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
var saw429 bool
for i := 0; i < adminPlanFeaturesGetRPM+5; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/admin/plans/1/features", nil)
req = req.WithContext(context.WithValue(req.Context(), ctxUserID, uid))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == http.StatusTooManyRequests {
saw429 = true
break
}
if rec.Code != http.StatusNoContent {
t.Fatalf("unexpected status %d", rec.Code)
}
}
if !saw429 {
t.Fatal("expected 429 after plan-features GET burst")
}
}
func TestRateLimitAdminAnalyticsBlocksBurst(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{}}
uid := uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc")
h := s.RateLimitAdminAnalytics(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
var saw429 bool
for i := 0; i < adminAnalyticsGetRPM+5; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/admin/analytics", nil)
req = req.WithContext(context.WithValue(req.Context(), ctxUserID, uid))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == http.StatusTooManyRequests {
saw429 = true
break
}
if rec.Code != http.StatusNoContent {
t.Fatalf("unexpected status %d", rec.Code)
}
}
if !saw429 {
t.Fatal("expected 429 after analytics GET burst")
}
}
func TestIsAdminAnalyticsGet(t *testing.T) {
t.Parallel()
cases := []struct {
method string
path string
want bool
}{
{http.MethodGet, "/api/admin/analytics", true},
{http.MethodGet, "/api/admin/analytics/", true},
{http.MethodGet, "/api/admin/diagnostics", true},
{http.MethodGet, "/api/admin/diagnostics/", true},
{http.MethodPost, "/api/admin/analytics", false},
{http.MethodGet, "/api/admin/readiness", false},
{http.MethodGet, "/api/admin/jobs", false},
}
for _, tc := range cases {
req := httptest.NewRequest(tc.method, tc.path, nil)
if got := isAdminAnalyticsGet(req); got != tc.want {
t.Fatalf("%s %s: got %v want %v", tc.method, tc.path, got, tc.want)
}
}
}
func TestIsAIOrMailProbePOST(t *testing.T) {
t.Parallel()
cases := []struct {
method string
path string
want bool
}{
{http.MethodPost, "/api/integrations/ai/test", true},
{http.MethodPost, "/api/admin/settings/mail/test", true},
{http.MethodPost, "/api/admin/settings/ai-roles/support/test", true},
{http.MethodGet, "/api/integrations/ai/test", false},
{http.MethodPost, "/api/admin/settings", false},
}
for _, tc := range cases {
req := httptest.NewRequest(tc.method, tc.path, nil)
if got := isAIOrMailProbePOST(req); got != tc.want {
t.Fatalf("%s %s: got %v want %v", tc.method, tc.path, got, tc.want)
}
}
}
func TestRateLimitAIProbesBlocksBurst(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{}}
uid := uuid.MustParse("dddddddd-dddd-dddd-dddd-dddddddddddd")
h := s.RateLimitAIProbes(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
var saw429 bool
for i := 0; i < aiProbeRPM+5; i++ {
req := httptest.NewRequest(http.MethodPost, "/api/integrations/ai/test", nil)
req = req.WithContext(context.WithValue(req.Context(), ctxUserID, uid))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == http.StatusTooManyRequests {
saw429 = true
break
}
if rec.Code != http.StatusNoContent {
t.Fatalf("unexpected status %d", rec.Code)
}
}
if !saw429 {
t.Fatal("expected 429 after AI probe burst")
}
}
func TestIsAdminPlanFeaturesGet(t *testing.T) {
t.Parallel()
cases := []struct {
method string
path string
want bool
}{
{http.MethodGet, "/api/admin/plans/1/features", true},
{http.MethodGet, "/api/admin/plans/99/features/", true},
{http.MethodPut, "/api/admin/plans/1/features", false},
{http.MethodGet, "/api/admin/plans", false},
{http.MethodGet, "/api/admin/feature-gates", false},
{http.MethodPost, "/api/admin/plans/1/features/enable-all", false},
}
for _, tc := range cases {
req := httptest.NewRequest(tc.method, tc.path, nil)
if got := isAdminPlanFeaturesGet(req); got != tc.want {
t.Fatalf("%s %s: got %v want %v", tc.method, tc.path, got, tc.want)
}
}
}
func TestIsHeavyFeedOrProcessMutation(t *testing.T) {
t.Parallel()
cases := []struct {
method string
path string
want bool
}{
{http.MethodPost, "/api/v1/feeds/abc/sync", true},
{http.MethodPost, "/api/feeds/abc/sync", true},
{http.MethodPost, "/api/v1/feeds/abc/extract-schema", true},
{http.MethodPost, "/api/feeds/abc/extract-schema", true},
{http.MethodPost, "/api/v1/feeds/abc/sync-process-sample", true},
{http.MethodPost, "/api/v1/process", true},
{http.MethodPost, "/api/processing/jobs", true},
{http.MethodPost, "/api/processing/jobs/abc/retry", true},
{http.MethodPost, "/api/v1/process/abc/retry", true},
{http.MethodPost, "/api/export-feeds/abc/generate", true},
{http.MethodPost, "/api/v1/export-feeds/abc/generate", true},
{http.MethodPost, "/api/export-feeds/abc/export-products", true},
{http.MethodPost, "/api/v1/export-feeds/abc/export-products", true},
{http.MethodGet, "/api/v1/feeds/abc/sync", false},
{http.MethodPost, "/api/v1/feeds/abc/mappings", false},
{http.MethodPost, "/api/integrations/shopify/sync", false},
{http.MethodPost, "/api/woocommerce/sync", false},
{http.MethodPost, "/api/processing/jobs/abc/cancel", false},
{http.MethodPost, "/api/campaigns/abc/generate", false},
}
for _, tc := range cases {
req := httptest.NewRequest(tc.method, tc.path, nil)
if got := isHeavyFeedOrProcessMutation(req); got != tc.want {
t.Fatalf("%s %s: got %v want %v", tc.method, tc.path, got, tc.want)
}
}
}
func TestRateLimitV1ProcessBlocksBurst(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{}}
h := s.RateLimitV1Process(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
var saw429 bool
for i := 0; i < heavyMutationRPM+5; i++ {
req := httptest.NewRequest(http.MethodPost, "/api/export-feeds/abc/generate", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == http.StatusTooManyRequests {
saw429 = true
if rec.Header().Get("Retry-After") == "" {
t.Fatal("expected Retry-After on 429")
}
if rec.Header().Get("RateLimit") == "" {
t.Fatal("expected RateLimit on 429")
}
if rec.Header().Get("RateLimit-Policy") == "" {
t.Fatal("expected RateLimit-Policy on 429")
}
break
}
if rec.Code != http.StatusNoContent {
t.Fatalf("unexpected status %d", rec.Code)
}
}
if !saw429 {
t.Fatal("expected 429 after heavy mutation burst")
}
}
func TestRateLimitEffectiveCap(t *testing.T) {
t.Parallel()
if got := rateLimitEffectiveCap(30, 1); got != 30 {
t.Fatalf("replicas=1 want 30 got %d", got)
}
if got := rateLimitEffectiveCap(30, 3); got != 10 {
t.Fatalf("replicas=3 want 10 got %d", got)
}
if got := rateLimitEffectiveCap(30, 7); got != 5 {
t.Fatalf("replicas=7 want ceil(30/7)=5 got %d", got)
}
if got := rateLimitEffectiveCap(0, 2); got != 1 {
t.Fatalf("base<=0 want 1 got %d", got)
}
if got := rateLimitEffectiveCap(10, 0); got != 10 {
t.Fatalf("replicas<=1 want base got %d", got)
}
}
func TestRateLimitAuthRespectsReplicas(t *testing.T) {
t.Parallel()
// ceil(authRegisterRPM/5)=1 → second register must 429
s := &Server{Config: config.Config{RateLimitReplicas: authRegisterRPM}}
h := s.RateLimitAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req1 := httptest.NewRequest(http.MethodPost, "/api/auth/register", nil)
req1.RemoteAddr = "203.0.113.50:40000"
rec1 := httptest.NewRecorder()
h.ServeHTTP(rec1, req1)
if rec1.Code != http.StatusNoContent {
t.Fatalf("first status=%d", rec1.Code)
}
req2 := httptest.NewRequest(http.MethodPost, "/api/auth/register", nil)
req2.RemoteAddr = "203.0.113.50:40000"
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusTooManyRequests {
t.Fatalf("second status=%d want 429", rec2.Code)
}
}
func TestRateLimitV1ProcessRespectsReplicas(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{RateLimitReplicas: heavyMutationRPM}}
h := s.RateLimitV1Process(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
// ceil(30/30)=1 → second request must 429
req1 := httptest.NewRequest(http.MethodPost, "/api/v1/process", nil)
rec1 := httptest.NewRecorder()
h.ServeHTTP(rec1, req1)
if rec1.Code != http.StatusNoContent {
t.Fatalf("first status=%d", rec1.Code)
}
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/process", nil)
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusTooManyRequests {
t.Fatalf("second status=%d want 429", rec2.Code)
}
}
func TestRateLimitV1ProcessSeparateCompanies(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{}}
h := s.RateLimitV1Process(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
cidA := uuid.MustParse("11111111-1111-1111-1111-111111111111")
cidB := uuid.MustParse("22222222-2222-2222-2222-222222222222")
for i := 0; i < heavyMutationRPM; i++ {
req := httptest.NewRequest(http.MethodPost, "/api/v1/process", nil)
req = req.WithContext(context.WithValue(req.Context(), ctxCompanyID, cidA))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("company A request %d: status %d", i, rec.Code)
}
}
blocked := httptest.NewRequest(http.MethodPost, "/api/v1/process", nil)
blocked = blocked.WithContext(context.WithValue(blocked.Context(), ctxCompanyID, cidA))
recBlocked := httptest.NewRecorder()
h.ServeHTTP(recBlocked, blocked)
if recBlocked.Code != http.StatusTooManyRequests {
t.Fatalf("company A should be limited, got %d", recBlocked.Code)
}
okB := httptest.NewRequest(http.MethodPost, "/api/export-feeds/abc/generate", nil)
okB = okB.WithContext(context.WithValue(okB.Context(), ctxCompanyID, cidB))
recB := httptest.NewRecorder()
h.ServeHTTP(recB, okB)
if recB.Code != http.StatusNoContent {
t.Fatalf("company B should not share A budget, got %d", recB.Code)
}
}
func TestRateLimitPublicBlocksBurst(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{}}
h := s.RateLimitPublic(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
var saw429 bool
for i := 0; i < 40; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/public/unsubscribe", nil)
req.RemoteAddr = "203.0.113.50:12345"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == http.StatusTooManyRequests {
saw429 = true
break
}
if rec.Code != http.StatusNoContent {
t.Fatalf("unexpected status %d", rec.Code)
}
}
if !saw429 {
t.Fatal("expected 429 after public burst")
}
}
func TestRateLimitPublicExportRejectsBadTokenShape(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{}}
r := chi.NewRouter()
r.With(s.RateLimitPublicExport).Get("/export-feeds/{token}.xml", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
req := httptest.NewRequest(http.MethodGet, "/export-feeds/short.xml", nil)
req.RemoteAddr = "203.0.113.60:1"
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for bad token shape, got %d", rec.Code)
}
}
func TestRateLimitPublicExportBlocksIPBurst(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{}}
r := chi.NewRouter()
r.With(s.RateLimitPublicExport).Get("/export-feeds/{token}.xml", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
token := "0123456789abcdef0123456789abcdef"
var saw429 bool
for i := 0; i < 40; i++ {
req := httptest.NewRequest(http.MethodGet, "/export-feeds/"+token+".xml", nil)
req.RemoteAddr = "203.0.113.61:1"
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code == http.StatusTooManyRequests {
saw429 = true
break
}
if rec.Code != http.StatusNoContent {
t.Fatalf("unexpected status %d", rec.Code)
}
}
if !saw429 {
t.Fatal("expected 429 after public export burst")
}
}
func TestRateLimitAPIKeyAttemptsBlocksBurst(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{}}
h := s.RateLimitAPIKeyAttempts(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
var saw429 bool
for i := 0; i < apiKeyAttemptRPM+5; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
req.RemoteAddr = "203.0.113.70:12345"
req.Header.Set("X-API-Key", "dk_test_key")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == http.StatusTooManyRequests {
saw429 = true
break
}
if rec.Code != http.StatusNoContent {
t.Fatalf("unexpected status %d", rec.Code)
}
}
if !saw429 {
t.Fatal("expected 429 after API key attempt burst")
}
}
func TestRateLimitAPIKeyCompanyBudget(t *testing.T) {
t.Parallel()
s := &Server{Config: config.Config{}}
h := s.RateLimitAPIKey(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
cid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
var saw429 bool
for i := 0; i < apiKeyCompanyRPM+5; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
req = req.WithContext(context.WithValue(req.Context(), ctxCompanyID, cid))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == http.StatusTooManyRequests {
saw429 = true
break
}
if rec.Code != http.StatusNoContent {
t.Fatalf("unexpected status %d", rec.Code)
}
}
if !saw429 {
t.Fatal("expected 429 after API key company burst")
}
}
+157
View File
@@ -0,0 +1,157 @@
package httpapi
import (
"bytes"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"regexp"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/i18n"
)
const maxJSONBodyBytes = 2 << 20 // 2 MiB
var errJSONBodyTooLarge = errors.New("request body too large")
var errJSONTrailingContent = errors.New("request body must contain a single JSON object")
// secretLikeRE matches common secret material that must never appear in logs.
var secretLikeRE = regexp.MustCompile(`(?i)(password|passwd|secret|api[_-]?key|token|authorization|bearer|sk_live|sk_test|whsec_)[^\s]{0,64}`)
func redactForLog(msg string) string {
if msg == "" {
return msg
}
return secretLikeRE.ReplaceAllStringFunc(msg, func(m string) string {
parts := strings.SplitN(m, "=", 2)
if len(parts) == 2 {
return parts[0] + "=[REDACTED]"
}
if i := strings.IndexByte(m, ':'); i > 0 && i < 24 {
return m[:i+1] + "[REDACTED]"
}
return "[REDACTED]"
})
}
func JSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func Error(w http.ResponseWriter, status int, msg string) {
JSON(w, status, map[string]string{"error": PublicMessage(w, msg)})
}
// FieldError writes the usual public error string plus an additive field map:
//
// { "error": "...", "code": "...?", "fields": { "<field>": "..." } }
//
// `error` stays the localized human message. Optional `code` is a stable
// machine token (never translated). `fields` lets dashboards highlight inputs
// under any Accept-Language without English substring matching.
// Clients that only read `error` keep working (no BREAKING change).
func FieldError(w http.ResponseWriter, status int, msg string, code string, fields map[string]string) {
localized := PublicMessage(w, msg)
out := map[string]any{"error": localized}
if code != "" {
out["code"] = code
}
if len(fields) > 0 {
lf := make(map[string]string, len(fields))
for k, v := range fields {
if k == "" {
continue
}
text := v
if text == "" {
text = msg
}
lf[k] = PublicMessage(w, text)
}
if len(lf) > 0 {
out["fields"] = lf
}
}
JSON(w, status, out)
}
// CodedError writes the legacy public-API error envelope:
//
// { "error": { "code": "...", "message": "..." } }
//
// Used for /api/v1 API-key auth failures so clients migrating from Descrybe
// see the same shape as err(code, message) in the Next.js app.
// Code is never translated; message respects Accept-Language via Locale middleware.
func CodedError(w http.ResponseWriter, status int, code, message string) {
JSON(w, status, map[string]any{
"error": map[string]string{"code": code, "message": PublicMessage(w, message)},
})
}
// PublicMessage localizes a client-facing string for the request locale.
// Stable machine codes (password_not_set, maintenance, …) stay unchanged.
func PublicMessage(w http.ResponseWriter, msg string) string {
return i18n.T(localeOf(w), msg)
}
// LogAndError logs the real error server-side (secrets redacted) and returns a safe public message.
func LogAndError(w http.ResponseWriter, status int, publicMsg string, err error) {
if err != nil {
log.Printf("httpapi: %s: %s", publicMsg, redactForLog(err.Error()))
}
Error(w, status, publicMsg)
}
// ClientOrLog writes a known client message, or logs and returns publicFallback.
func ClientOrLog(w http.ResponseWriter, status int, publicFallback string, err error, clientMsg func(error) (string, bool)) {
if msg, ok := clientMsg(err); ok {
Error(w, status, msg)
return
}
LogAndError(w, status, publicFallback, err)
}
func DecodeJSON(r *http.Request, dst any) error {
return decodeJSON(r, dst, true)
}
// DecodeJSONAllowUnknown decodes JSON without DisallowUnknownFields.
// Used for legacy public process payloads that may include extra item keys.
func DecodeJSONAllowUnknown(r *http.Request, dst any) error {
return decodeJSON(r, dst, false)
}
func decodeJSON(r *http.Request, dst any, disallowUnknown bool) error {
defer r.Body.Close()
data, err := io.ReadAll(io.LimitReader(r.Body, maxJSONBodyBytes+1))
if err != nil {
return err
}
if len(data) > maxJSONBodyBytes {
return errJSONBodyTooLarge
}
dec := json.NewDecoder(bytes.NewReader(data))
if disallowUnknown {
dec.DisallowUnknownFields()
}
if err := dec.Decode(dst); err != nil {
return err
}
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errJSONTrailingContent
}
return nil
}
func DecodeJSONOptional(r *http.Request, dst any) error {
err := DecodeJSON(r, dst)
if errors.Is(err, io.EOF) {
return nil
}
return err
}
@@ -0,0 +1,73 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestCodedErrorLegacyEnvelope(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
CodedError(rec, http.StatusUnauthorized, "unauthorized", "Unauthorized")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d", rec.Code)
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("json: %v body=%s", err, rec.Body.String())
}
if body.Error.Code != "unauthorized" || body.Error.Message != "Unauthorized" {
t.Fatalf("got %+v", body.Error)
}
}
func TestFieldErrorAdditiveShape(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
FieldError(rec, http.StatusUnauthorized, "invalid credentials", "invalid_credentials", map[string]string{
"email": "invalid credentials",
"password": "invalid credentials",
})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d", rec.Code)
}
var body struct {
Error string `json:"error"`
Code string `json:"code"`
Fields map[string]string `json:"fields"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("json: %v body=%s", err, rec.Body.String())
}
if body.Error != "invalid credentials" || body.Code != "invalid_credentials" {
t.Fatalf("got error=%q code=%q", body.Error, body.Code)
}
if body.Fields["email"] != "invalid credentials" || body.Fields["password"] != "invalid credentials" {
t.Fatalf("fields=%v", body.Fields)
}
}
func TestOKLegacyDataMetaEnvelope(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
OK(rec, http.StatusOK, map[string]any{"id": "x"}, map[string]any{"page": 1, "limit": 25, "total": 10})
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
data, _ := body["data"].(map[string]any)
meta, _ := body["meta"].(map[string]any)
if data["id"] != "x" {
t.Fatalf("data=%v", data)
}
if meta["page"].(float64) != 1 || meta["total"].(float64) != 10 {
t.Fatalf("meta=%v", meta)
}
}
+72
View File
@@ -0,0 +1,72 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestDecodeJSONRejectsTrailingContent(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/x", strings.NewReader(`{"name":"ok"}{"extra":true}`))
var body struct {
Name string `json:"name"`
}
err := DecodeJSON(r, &body)
if err == nil {
t.Fatal("expected trailing content error")
}
if err != errJSONTrailingContent {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDecodeJSONOptionalAllowsEmptyBody(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/x", http.NoBody)
var body struct {
Name string `json:"name"`
}
if err := DecodeJSONOptional(r, &body); err != nil {
t.Fatalf("expected empty body to be allowed, got %v", err)
}
}
func TestDecodeJSONOptionalRejectsMalformedJSON(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/x", strings.NewReader(`{"name":`))
var body struct {
Name string `json:"name"`
}
if err := DecodeJSONOptional(r, &body); err == nil {
t.Fatal("expected malformed json error")
}
}
// SPA start-job payload includes processing_types alongside processing_type.
// DecodeJSON DisallowUnknownFields must accept both or POST /api/processing/jobs returns 400.
func TestDecodeJSONAcceptsStartJobSPAPayload(t *testing.T) {
payload := `{"raw_product_ids":["11111111-1111-1111-1111-111111111111"],"processing_type":"full","processing_types":["category","title"]}`
r := httptest.NewRequest(http.MethodPost, "/api/processing/jobs", strings.NewReader(payload))
var body startProcessingJobRequest
if err := DecodeJSON(r, &body); err != nil {
t.Fatalf("expected SPA start-job payload to decode, got %v", err)
}
if body.ProcessingType != "full" {
t.Fatalf("processing_type = %q", body.ProcessingType)
}
if len(body.RawProductIDs) != 1 || len(body.ProcessingTypes) != 2 {
t.Fatalf("unexpected body: %+v", body)
}
}
func TestDecodeJSONRejectsUnknownStartJobField(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/x", strings.NewReader(`{"raw_product_ids":[],"processing_type":"full","unknown":true}`))
var body startProcessingJobRequest
if err := DecodeJSON(r, &body); err == nil {
t.Fatal("expected unknown field to be rejected")
}
}
+258
View File
@@ -0,0 +1,258 @@
package httpapi
import (
"net/http"
"strconv"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/sales"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func (s *Server) salesSvc() *sales.Service {
if s.Sales != nil {
return s.Sales
}
s.Sales = &sales.Service{Pool: s.Pool}
return s.Sales
}
type salesContactBody struct {
Name string `json:"name"`
Email string `json:"email"`
CompanyName string `json:"company_name"`
Phone string `json:"phone"`
Message string `json:"message"`
EstimatedSKUs *int `json:"estimated_skus"`
Source string `json:"source"`
}
// handleSalesContact is public (CSRF required, session optional).
func (s *Server) handleSalesContact(w http.ResponseWriter, r *http.Request) {
var body salesContactBody
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
in := sales.CreateLeadInput{
Name: body.Name,
Email: body.Email,
CompanyName: body.CompanyName,
Phone: body.Phone,
Message: body.Message,
EstimatedSKUs: body.EstimatedSKUs,
Source: body.Source,
}
if uid, ok := UserIDFromContext(r.Context()); ok && uid != uuid.Nil {
in.UserID = &uid
}
if cid, ok := CompanyIDFromContext(r.Context()); ok && cid != uuid.Nil {
in.CompanyID = &cid
}
lead, err := s.salesSvc().CreateLead(r.Context(), in)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not submit contact request", err, sales.ClientError)
return
}
JSON(w, http.StatusCreated, map[string]any{"lead": lead})
}
func (s *Server) handleAdminListSalesLeads(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
q := r.URL.Query().Get("q")
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
leads, total, err := s.salesSvc().ListLeads(r.Context(), status, q, limit, offset)
if err != nil {
ClientOrLog(w, http.StatusInternalServerError, "could not list sales leads", err, sales.ClientError)
return
}
JSON(w, http.StatusOK, map[string]any{"leads": leads, "total": total})
}
func (s *Server) handleAdminGetSalesLead(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
lead, err := s.salesSvc().GetLead(r.Context(), id)
if err != nil {
ClientOrLog(w, http.StatusNotFound, "lead not found", err, sales.ClientError)
return
}
quotes, err := s.salesSvc().ListQuotesForLead(r.Context(), id)
if err != nil {
ClientOrLog(w, http.StatusInternalServerError, "could not list quotes", err, sales.ClientError)
return
}
JSON(w, http.StatusOK, map[string]any{"lead": lead, "quotes": quotes})
}
type adminUpdateSalesLeadBody struct {
Status *string `json:"status"`
CompanyID *uuid.UUID `json:"company_id"`
ClearCompany bool `json:"clear_company"`
AdminNotes *string `json:"admin_notes"`
}
func (s *Server) handleAdminUpdateSalesLead(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body adminUpdateSalesLeadBody
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
lead, err := s.salesSvc().UpdateLead(r.Context(), id, sales.UpdateLeadInput{
Status: body.Status,
CompanyID: body.CompanyID,
ClearCompany: body.ClearCompany,
AdminNotes: body.AdminNotes,
})
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update lead", err, sales.ClientError)
return
}
JSON(w, http.StatusOK, map[string]any{"lead": lead})
}
type adminCreateSalesQuoteBody struct {
CompanyID uuid.UUID `json:"company_id"`
PlanName string `json:"plan_name"`
MonthlyCredits int `json:"monthly_credits"`
MaxProducts *int `json:"max_products"`
Currency string `json:"currency"`
TotalAmountCents int `json:"total_amount_cents"`
InstallmentCount int `json:"installment_count"`
InstallmentInterval string `json:"installment_interval"`
TermMonths *int `json:"term_months"`
PrepareCheckout bool `json:"prepare_checkout"`
}
func (s *Server) handleAdminCreateSalesQuote(w http.ResponseWriter, r *http.Request) {
leadID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body adminCreateSalesQuoteBody
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
var createdBy *uuid.UUID
if uid, ok := UserIDFromContext(r.Context()); ok && uid != uuid.Nil {
createdBy = &uid
}
quote, err := s.salesSvc().CreateQuote(r.Context(), leadID, sales.CreateQuoteInput{
CompanyID: body.CompanyID,
PlanName: body.PlanName,
MonthlyCredits: body.MonthlyCredits,
MaxProducts: body.MaxProducts,
Currency: body.Currency,
TotalAmountCents: body.TotalAmountCents,
InstallmentCount: body.InstallmentCount,
InstallmentInterval: body.InstallmentInterval,
TermMonths: body.TermMonths,
CreatedByUserID: createdBy,
})
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not create quote", err, sales.ClientError)
return
}
if body.PrepareCheckout {
quote, err = s.prepareSalesQuoteCheckout(r, quote)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "quote created but checkout failed", err, func(e error) (string, bool) {
if msg, ok := sales.ClientError(e); ok {
return msg, true
}
return billing.ClientError(e)
})
return
}
}
JSON(w, http.StatusCreated, map[string]any{"quote": quote})
}
func (s *Server) handleAdminPrepareSalesQuoteCheckout(w http.ResponseWriter, r *http.Request) {
quoteID, err := uuid.Parse(chi.URLParam(r, "quoteID"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid quote id")
return
}
quote, err := s.salesSvc().GetQuote(r.Context(), quoteID)
if err != nil {
ClientOrLog(w, http.StatusNotFound, "quote not found", err, sales.ClientError)
return
}
quote, err = s.prepareSalesQuoteCheckout(r, quote)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not prepare checkout", err, func(e error) (string, bool) {
if msg, ok := sales.ClientError(e); ok {
return msg, true
}
return billing.ClientError(e)
})
return
}
JSON(w, http.StatusOK, map[string]any{"quote": quote})
}
func (s *Server) handleAdminMarkSalesQuoteSent(w http.ResponseWriter, r *http.Request) {
quoteID, err := uuid.Parse(chi.URLParam(r, "quoteID"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid quote id")
return
}
quote, err := s.salesSvc().MarkQuoteSent(r.Context(), quoteID)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not mark quote sent", err, sales.ClientError)
return
}
JSON(w, http.StatusOK, map[string]any{"quote": quote})
}
func (s *Server) prepareSalesQuoteCheckout(r *http.Request, quote sales.Quote) (sales.Quote, error) {
if quote.PlanID == nil || *quote.PlanID <= 0 {
return quote, sales.ErrQuoteNotReady
}
if quote.Status == "paid" || quote.Status == "canceled" {
return quote, sales.ErrQuoteNotReady
}
var companyName, billingEmail string
_ = s.Pool.QueryRow(r.Context(), `SELECT name FROM companies WHERE id = $1`, quote.CompanyID).Scan(&companyName)
lead, err := s.salesSvc().GetLead(r.Context(), quote.LeadID)
if err == nil {
billingEmail = lead.Email
}
res, err := s.stripeSvc().CreateSalesQuoteCheckout(r.Context(), billing.SalesQuoteCheckoutInput{
QuoteID: quote.ID,
CompanyID: quote.CompanyID,
PlanID: *quote.PlanID,
PlanName: quote.PlanName,
Email: billingEmail,
CompanyName: companyName,
Currency: quote.Currency,
TotalAmountCents: quote.TotalAmountCents,
InstallmentCount: quote.InstallmentCount,
InstallmentInterval: quote.InstallmentInterval,
InstallmentAmountCents: quote.InstallmentAmountCents,
})
if err != nil {
return quote, err
}
if res.Applied {
updated, getErr := s.salesSvc().GetQuote(r.Context(), quote.ID)
if getErr != nil {
return quote, getErr
}
return updated, nil
}
return s.salesSvc().MarkQuoteCheckoutReady(r.Context(), quote.ID, res.ProductID, res.PriceID, res.SessionID, res.URL)
}
@@ -0,0 +1,43 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
)
func TestRouterSalesRoutesMounted(t *testing.T) {
t.Parallel()
sm := scs.New()
sm.Cookie.Name = "descrybe_session"
s := &Server{
Config: config.Config{
CSRFCookieName: "descrybe_csrf",
WebOrigin: "http://localhost:5173",
},
Sessions: sm,
}
h := s.Router()
// CSRF rejects anonymous POST without token.
contact := httptest.NewRecorder()
h.ServeHTTP(contact, httptest.NewRequest(http.MethodPost, "/api/sales/contact", nil))
if contact.Code == http.StatusNotFound {
t.Fatal("POST /api/sales/contact not mounted")
}
if contact.Code != http.StatusForbidden {
t.Fatalf("contact status=%d want 403 body=%s", contact.Code, contact.Body.String())
}
unauth := httptest.NewRecorder()
h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/admin/sales/leads", nil))
if unauth.Code == http.StatusNotFound {
t.Fatal("GET /api/admin/sales/leads not mounted")
}
if unauth.Code != http.StatusUnauthorized {
t.Fatalf("admin leads status=%d want 401 body=%s", unauth.Code, unauth.Body.String())
}
}
@@ -0,0 +1,92 @@
package httpapi
import (
"net"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
)
// TrustedRealIP rewrites RemoteAddr from client IP headers only when the
// immediate peer is listed in TRUSTED_PROXIES. Empty allowlist leaves
// RemoteAddr unchanged (ignores spoofable X-Forwarded-For / X-Real-IP).
func TrustedRealIP(trusted []string) func(http.Handler) http.Handler {
nets, err := config.ParseTrustedProxyNets(trusted)
if err != nil || len(nets) == 0 {
return func(next http.Handler) http.Handler { return next }
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isTrustedPeer(r.RemoteAddr, nets) {
if rip := clientIPFromProxyHeaders(r); rip != "" {
r.RemoteAddr = rip
}
}
next.ServeHTTP(w, r)
})
}
}
// apiContentSecurityPolicy is a strict CSP for JSON API responses (no HTML/scripts).
const apiContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
// SecurityHeaders sets baseline API response headers. HSTS is only emitted
// when session cookies are marked Secure (HTTPS deployments).
func SecurityHeaders(sessionSecure bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
h.Set("Content-Security-Policy", apiContentSecurityPolicy)
if sessionSecure {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
}
func isTrustedPeer(remoteAddr string, nets []*net.IPNet) bool {
ip := peerIP(remoteAddr)
if ip == nil {
return false
}
for _, n := range nets {
if n.Contains(ip) {
return true
}
}
return false
}
func peerIP(remoteAddr string) net.IP {
host := strings.TrimSpace(remoteAddr)
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
return net.ParseIP(host)
}
func clientIPFromProxyHeaders(r *http.Request) string {
var ip string
if tcip := r.Header.Get("True-Client-IP"); tcip != "" {
ip = tcip
} else if xrip := r.Header.Get("X-Real-IP"); xrip != "" {
ip = xrip
} else if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
i := strings.Index(xff, ",")
if i == -1 {
i = len(xff)
}
ip = xff[:i]
}
ip = strings.TrimSpace(ip)
if ip == "" || net.ParseIP(ip) == nil {
return ""
}
return ip
}
@@ -0,0 +1,157 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestTrustedRealIPIgnoresHeadersWithoutAllowlist(t *testing.T) {
t.Parallel()
h := TrustedRealIP(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.RemoteAddr != "203.0.113.10:12345" {
t.Fatalf("RemoteAddr = %q, want peer unchanged", r.RemoteAddr)
}
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
req.RemoteAddr = "203.0.113.10:12345"
req.Header.Set("X-Forwarded-For", "198.51.100.1")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d", rec.Code)
}
}
func TestTrustedRealIPIgnoresHeadersFromUntrustedPeer(t *testing.T) {
t.Parallel()
h := TrustedRealIP([]string{"10.0.0.0/8"})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.RemoteAddr != "203.0.113.10:12345" {
t.Fatalf("RemoteAddr = %q, want peer unchanged", r.RemoteAddr)
}
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
req.RemoteAddr = "203.0.113.10:12345"
req.Header.Set("X-Forwarded-For", "198.51.100.1")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d", rec.Code)
}
}
func TestTrustedRealIPRewritesFromTrustedPeer(t *testing.T) {
t.Parallel()
h := TrustedRealIP([]string{"10.0.0.1"})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.RemoteAddr != "198.51.100.1" {
t.Fatalf("RemoteAddr = %q, want client IP from XFF", r.RemoteAddr)
}
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
req.RemoteAddr = "10.0.0.1:443"
req.Header.Set("X-Forwarded-For", "198.51.100.1, 10.0.0.1")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d", rec.Code)
}
}
func TestSecurityHeadersBaseline(t *testing.T) {
t.Parallel()
h := SecurityHeaders(false)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options = %q", got)
}
if got := rec.Header().Get("X-Frame-Options"); got != "DENY" {
t.Fatalf("X-Frame-Options = %q", got)
}
if got := rec.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" {
t.Fatalf("Referrer-Policy = %q", got)
}
if got := rec.Header().Get("Content-Security-Policy"); got != apiContentSecurityPolicy {
t.Fatalf("Content-Security-Policy = %q, want %q", got, apiContentSecurityPolicy)
}
if got := rec.Header().Get("Content-Security-Policy-Report-Only"); got != "" {
t.Fatalf("unexpected Report-Only CSP: %q", got)
}
if got := rec.Header().Get("Strict-Transport-Security"); got != "" {
t.Fatalf("HSTS unexpectedly set: %q", got)
}
}
func TestSecurityHeadersAPIContentSecurityPolicy(t *testing.T) {
t.Parallel()
if !strings.Contains(apiContentSecurityPolicy, "default-src 'none'") {
t.Fatalf("API CSP missing default-src 'none': %q", apiContentSecurityPolicy)
}
if !strings.Contains(apiContentSecurityPolicy, "frame-ancestors 'none'") {
t.Fatalf("API CSP missing frame-ancestors 'none': %q", apiContentSecurityPolicy)
}
if !strings.Contains(apiContentSecurityPolicy, "form-action 'none'") {
t.Fatalf("API CSP missing form-action 'none': %q", apiContentSecurityPolicy)
}
if strings.Contains(apiContentSecurityPolicy, "'unsafe-inline'") || strings.Contains(apiContentSecurityPolicy, "'unsafe-eval'") {
t.Fatalf("API CSP must not allow unsafe script: %q", apiContentSecurityPolicy)
}
if strings.Contains(apiContentSecurityPolicy, "ws:") || strings.Contains(apiContentSecurityPolicy, "wss:") {
t.Fatalf("API CSP must not allow websocket schemes: %q", apiContentSecurityPolicy)
}
}
func TestSecurityHeadersHSTSWhenSecure(t *testing.T) {
t.Parallel()
h := SecurityHeaders(true)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if got := rec.Header().Get("Strict-Transport-Security"); got == "" {
t.Fatal("expected HSTS when SessionSecure")
}
}
func TestCORSAllowsConfiguredOriginOnly(t *testing.T) {
t.Parallel()
s := testAPIServer()
s.Config.WebOrigin = "http://localhost:5174"
h := s.Router()
ok := httptest.NewRecorder()
reqOK := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
reqOK.Header.Set("Origin", "http://localhost:5174")
reqOK.Header.Set("Access-Control-Request-Method", "POST")
h.ServeHTTP(ok, reqOK)
if got := ok.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5174" {
t.Fatalf("allow origin = %q", got)
}
if got := ok.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("allow credentials = %q", got)
}
twin := httptest.NewRecorder()
reqTwin := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
reqTwin.Header.Set("Origin", "http://127.0.0.1:5174")
reqTwin.Header.Set("Access-Control-Request-Method", "POST")
h.ServeHTTP(twin, reqTwin)
if got := twin.Header().Get("Access-Control-Allow-Origin"); got != "http://127.0.0.1:5174" {
t.Fatalf("loopback twin allow origin = %q", got)
}
bad := httptest.NewRecorder()
reqBad := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
reqBad.Header.Set("Origin", "https://evil.example")
reqBad.Header.Set("Access-Control-Request-Method", "POST")
h.ServeHTTP(bad, reqBad)
if got := bad.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("unexpected allow origin for evil: %q", got)
}
}
+66
View File
@@ -0,0 +1,66 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/seo"
"github.com/google/uuid"
)
func (s *Server) handleSEORecommendations(w http.ResponseWriter, r *http.Request) {
cid, ok := CompanyIDFromContext(r.Context())
if !ok || cid == uuid.Nil {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
if s.SEO == nil {
Error(w, http.StatusServiceUnavailable, "seo service unavailable")
return
}
report, err := s.SEO.Recommendations(r.Context(), cid)
if err != nil {
Error(w, http.StatusInternalServerError, "seo analysis failed")
return
}
JSON(w, http.StatusOK, report)
}
func (s *Server) handleSEOApply(w http.ResponseWriter, r *http.Request) {
cid, ok := CompanyIDFromContext(r.Context())
if !ok || cid == uuid.Nil {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
if s.SEO == nil {
Error(w, http.StatusServiceUnavailable, "seo service unavailable")
return
}
var body seo.ApplyRequest
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
productID, err := uuid.Parse(strings.TrimSpace(body.ProductID))
if err != nil {
Error(w, http.StatusBadRequest, "invalid product_id")
return
}
result, err := s.SEO.Apply(r.Context(), cid, productID, body.Mode)
if err != nil {
switch {
case writePlanGate(w, err):
return
case errors.Is(err, seo.ErrNotFound):
Error(w, http.StatusNotFound, "not found")
return
default:
ClientOrLog(w, http.StatusBadRequest, "seo apply failed", err, seo.ClientError)
return
}
}
JSON(w, http.StatusOK, result)
}
+629
View File
@@ -0,0 +1,629 @@
package httpapi
import (
"context"
"net/http"
"sync"
"time"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/campaigns"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/descrybe/descrybe-v2/apps/api/internal/email"
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
"github.com/descrybe/descrybe-v2/apps/api/internal/jobs"
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
"github.com/descrybe/descrybe-v2/apps/api/internal/metrics"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/descrybe/descrybe-v2/apps/api/internal/sales"
"github.com/descrybe/descrybe-v2/apps/api/internal/seo"
"github.com/descrybe/descrybe-v2/apps/api/internal/shopify"
"github.com/descrybe/descrybe-v2/apps/api/internal/support"
"github.com/descrybe/descrybe-v2/apps/api/internal/woocommerce"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type Server struct {
Config config.Config
Pool *pgxpool.Pool
Sessions *scs.SessionManager
Auth *auth.Service
Catalog *catalog.Service
Feeds *feeds.Service
Billing *billing.Service
Stripe *billing.StripeService
Processing *processing.Pipeline
SEO *seo.Service
Jobs *jobs.Queue
Woo *woocommerce.Service
Shopify *shopify.Service
Mail mail.Mailer
Email *email.Service
AI *aiprovider.Service
AIPrompts *aiprompts.Service
Campaigns *campaigns.Service
Support *support.Service
Sales *sales.Service
PlatformSettings *platformsettings.Service
// testPlatformAdmin optional override for RequirePlatformAdmin unit tests.
testPlatformAdmin func(ctx context.Context, userID uuid.UUID) (bool, error)
// testStaffAccess optional override for RequireSupportDesk / RequirePlatformAdmin tests.
testStaffAccess func(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error)
// testAssertFeatures optional override for requireFeatures / plan-gate unit tests.
testAssertFeatures func(ctx context.Context, keys ...string) error
// testUserActive optional override for RequireSession active-account checks.
testUserActive func(ctx context.Context, userID uuid.UUID) (active bool, err error)
// testUserSessionState optional override for RequireSession active+version checks.
testUserSessionState func(ctx context.Context, userID uuid.UUID) (auth.UserSessionState, error)
// Optional overrides for legacy POST /products/process unit tests.
testEnsureRawV1Items func(ctx context.Context, companyID uuid.UUID, items []catalog.V1ProcessItem) (ids []uuid.UUID, results []catalog.EnsureRawResult, errs []string, err error)
testStartJobs func(ctx context.Context, companyID, userID uuid.UUID, rawIDs []uuid.UUID, processingType string) ([]processing.Job, error)
testEnqueueJob func(ctx context.Context, jobID uuid.UUID) error
testGetJob func(ctx context.Context, companyID, id uuid.UUID) (processing.Job, error)
testLoadV1ProcessJobItems func(ctx context.Context, companyID, jobID uuid.UUID, processingType string) ([]processing.V1ProcessJobItem, error)
adminSetPasswordOnce sync.Once
adminSetPasswordReqRL *slidingWindowLimiter
adminSetPasswordSendRL *slidingWindowLimiter
forgotPasswordOnce sync.Once
forgotPasswordIPRL *slidingWindowLimiter
forgotPasswordEmailRL *slidingWindowLimiter
// loginLockout is email-keyed failed-password lockout (in-process; see login_lockout.go).
loginLockoutOnce sync.Once
loginLockout *loginAttemptLockout
}
func NewServer(
cfg config.Config,
pool *pgxpool.Pool,
sessions *scs.SessionManager,
) *Server {
billingSvc := &billing.Service{Pool: pool}
emailSvc := email.NewService(pool, email.EnvConfig{
AppEncryptionKey: cfg.AppEncryptionKey,
CredentialsEncryptionKey: cfg.CredentialsEncryptionKey,
TokenSigningSecret: cfg.TokenSigningSecret,
DatabaseURL: cfg.DatabaseURL,
PublicAPIURL: cfg.PublicAPIURL,
WebOrigin: cfg.WebOrigin,
EmailDryRun: cfg.EmailDryRun,
ResendAPIKey: cfg.ResendAPIKey,
SMTPHost: cfg.SMTPHost,
SMTPPort: cfg.SMTPPort,
SMTPUser: cfg.SMTPUser,
SMTPPassword: cfg.SMTPPassword,
SMTPFrom: cfg.SMTPFrom,
SendRPM: cfg.EmailSendRPM,
SendRPH: cfg.EmailSendRPH,
})
aiSvc := aiprovider.NewService(pool, aiprovider.EnvConfig{
AppEncryptionKey: cfg.AppEncryptionKey,
CredentialsEncryptionKey: cfg.CredentialsEncryptionKey,
TokenSigningSecret: cfg.TokenSigningSecret,
DatabaseURL: cfg.DatabaseURL,
OpenAIAPIKey: cfg.OpenAIAPIKey,
OpenAIBaseURL: cfg.OpenAIBaseURL,
OpenAIModel: cfg.OpenAIModel,
ProcessingRPM: cfg.ProcessingRPM,
ProcessingMaxRetries: cfg.ProcessingMaxRetries,
})
promptSvc := aiprompts.NewService(pool)
platformSettings := platformsettings.NewService(pool, platformsettings.EnvConfig{
AppEncryptionKey: cfg.AppEncryptionKey,
CredentialsEncryptionKey: cfg.CredentialsEncryptionKey,
TokenSigningSecret: cfg.TokenSigningSecret,
DatabaseURL: cfg.DatabaseURL,
OpenAIAPIKey: cfg.OpenAIAPIKey,
OpenAIBaseURL: cfg.OpenAIBaseURL,
OpenAIModel: cfg.OpenAIModel,
OpenAIEmbeddingAPIKey: cfg.OpenAIEmbeddingAPIKey,
OpenAIEmbeddingBaseURL: cfg.OpenAIEmbeddingBaseURL,
OpenAIEmbeddingModel: cfg.OpenAIEmbeddingModel,
SMTPEnabled: cfg.SMTPEnabled,
SMTPHost: cfg.SMTPHost,
SMTPPort: cfg.SMTPPort,
SMTPUser: cfg.SMTPUser,
SMTPPassword: cfg.SMTPPassword,
SMTPFrom: cfg.SMTPFrom,
ResendAPIKey: cfg.ResendAPIKey,
EmailDryRun: cfg.EmailDryRun,
EmailDryRunSet: cfg.EmailDryRunSet,
StripeSecretKey: cfg.StripeSecretKey,
StripeWebhookSecret: cfg.StripeWebhookSecret,
StripeMock: cfg.StripeMock,
StripePriceIDs: cfg.StripePriceIDs,
EPRELEnabled: cfg.EPRELEnabled,
EPRELBaseURL: cfg.EPRELBaseURL,
EPRELTimeout: cfg.EPRELTimeout,
EPRELFicheLanguage: cfg.EPRELFicheLanguage,
EPRELAPIKey: cfg.EPRELAPIKey,
PineconeAPIKey: cfg.PineconeAPIKey,
PineconeHost: cfg.PineconeHost,
PineconeNamespace: cfg.PineconeNamespace,
})
aiSvc.Platform = platformSettings
emailSvc.Platform = platformSettings
bootCtx, bootCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer bootCancel()
if csv, err := platformSettings.ResolveFeedPrivateAllowlist(bootCtx); err == nil {
feeds.ApplyPrivateAllowlistCSV(csv)
}
// OpenAI is resolved per request/job via aiprovider → platformsettings.ResolveOpenAI
// (no boot-time client snapshot).
_ = seo.EnsureCost(context.Background(), pool)
campaignSvc := campaigns.NewService(pool, billingSvc, emailSvc)
campaignSvc.WebOrigin = cfg.WebOrigin
campaignSvc.PublicAPIURL = cfg.PublicAPIURL
campaignSvc.TokenSigningSecret = cfg.TokenSigningSecret
campaignSvc.AI = aiSvc
campaignSvc.Prompts = promptSvc
pipeline := processing.NewPipeline(pool)
pipeline.AI = aiSvc
pipeline.Prompts = promptSvc
pipeline.Engine = &processing.Engine{
Vector: &platformsettings.DynamicPinecone{Settings: platformSettings},
ProviderMode: processing.AIProviderInternal,
}
s := &Server{
Config: cfg,
Pool: pool,
Sessions: sessions,
Auth: &auth.Service{Pool: pool},
Catalog: &catalog.Service{Pool: pool},
Feeds: &feeds.Service{Pool: pool, UploadDir: cfg.UploadDir},
Billing: billingSvc,
Stripe: &billing.StripeService{
Pool: pool,
Billing: billingSvc,
Cfg: billing.StripeConfig{
SecretKey: cfg.StripeSecretKey,
WebhookSecret: cfg.StripeWebhookSecret,
WebOrigin: cfg.WebOrigin,
PublicAPIURL: cfg.PublicAPIURL,
PriceIDs: cfg.StripePriceIDs,
ForceMock: cfg.StripeMock,
},
ResolveCfg: platformSettings.ResolveStripe,
},
Processing: pipeline,
SEO: &seo.Service{
Pool: pool,
Billing: billingSvc,
AI: aiSvc,
Prompts: promptSvc,
},
Jobs: jobs.NewQueue(pool),
Woo: woocommerce.NewService(pool, woocommerce.DeriveKey(
firstNonEmpty(cfg.AppEncryptionKey, cfg.CredentialsEncryptionKey, cfg.TokenSigningSecret),
cfg.DatabaseURL,
)),
Shopify: shopify.NewService(pool, shopify.DeriveKey(
firstNonEmpty(cfg.AppEncryptionKey, cfg.CredentialsEncryptionKey, cfg.TokenSigningSecret),
cfg.DatabaseURL,
)),
Mail: mail.NewDynamic(func() (mail.Config, error) {
ctx := context.Background()
dry, err := platformSettings.ResolveEmailDryRun(ctx)
if err != nil {
return mail.Config{}, err
}
resolved, err := platformSettings.ResolveSMTP(ctx)
if err != nil {
return mail.Config{}, err
}
return mail.ApplyDryRun(dry.DryRun, mail.ConfigFromParts(
resolved.Enabled,
resolved.Host,
resolved.Port,
resolved.User,
resolved.Password,
resolved.From,
)), nil
}),
Email: emailSvc,
AI: aiSvc,
AIPrompts: promptSvc,
Campaigns: campaignSvc,
Support: support.NewService(pool),
Sales: &sales.Service{Pool: pool},
PlatformSettings: platformSettings,
}
if s.Support != nil {
s.Support.SupportAI = support.NewCompleterSupportAI(aiSvc)
s.Support.AIRateLimiter = support.NewAIRateLimiter(0, 0)
}
return s
}
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(TrustedRealIP(s.Config.TrustedProxies))
r.Use(chimw.Logger)
r.Use(metrics.Middleware)
r.Use(chimw.Recoverer)
r.Use(chimw.Timeout(60 * time.Second))
r.Use(SecurityHeaders(s.Config.SessionSecure))
r.Use(cors.Handler(cors.Options{
AllowedOrigins: config.CORSAllowedOrigins(s.Config.WebOrigin),
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Accept-Language", "Authorization", "Content-Type", "X-API-Key", "X-CSRF-Token", "X-Company-ID"},
AllowCredentials: true,
MaxAge: 300,
}))
// After CORS so handlers see localeResponseWriter as the immediate writer.
r.Use(Locale)
// Liveness / readiness / metrics — no session dependency
r.Get("/healthz", s.handleHealthz)
r.Get("/readyz", s.handleReadyz)
metricsH := metrics.Gate(s.Config.IsProduction(), s.Config.MetricsPublic)(metrics.Handler())
r.Method(http.MethodGet, "/metrics", metricsH)
r.Method(http.MethodHead, "/metrics", metricsH)
// Public API-key surface: no session/CSRF; maintenance still applies.
r.Group(func(r chi.Router) {
r.Use(s.MaintenanceGate)
s.mountV1(r)
})
// Public token/HMAC routes (no session/CSRF).
// Mounted at /api/public BEFORE authenticated /api so unmatched public paths
// return 404 instead of falling into RequireSession (401).
r.Route("/api/public", func(r chi.Router) {
r.Use(s.MaintenanceGate)
r.Use(s.RateLimitPublic)
r.Get("/plans", s.handleListPublicPlans)
r.Get("/credit-packs", s.handleListCreditPacks)
r.Get("/brand-logo/{companyID}/{filename}", s.handlePublicBrandLogo)
r.Get("/support-kb/{filename}", s.handlePublicKBImage)
r.With(s.RateLimitPublicExport).Get("/export-feeds/{token}.xml", s.handlePublicExportXML)
r.With(s.RateLimitPublicExport).Get("/export-feeds/{token}.csv", s.handlePublicExportCSV)
r.Get("/unsubscribe", s.handlePublicUnsubscribeGet)
r.Post("/unsubscribe", s.handlePublicUnsubscribePost)
r.NotFound(func(w http.ResponseWriter, _ *http.Request) {
Error(w, http.StatusNotFound, "not found")
})
r.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
})
})
// Stripe webhooks (signature-verified; no session/CSRF).
r.Group(func(r chi.Router) {
r.Use(s.MaintenanceGate)
r.Post("/api/webhooks/stripe", s.handleStripeWebhook)
})
r.Group(func(r chi.Router) {
// Maintenance/read-only before session+CSRF so freeze returns 503 (not csrf 403).
r.Use(s.MaintenanceGate)
r.Use(LoadSession(s.Sessions))
r.Use(s.CSRF)
r.Route("/api/auth", func(r chi.Router) {
r.Use(s.RateLimitAuth)
r.Post("/register", s.handleRegister)
r.Post("/login", s.handleLogin)
r.Post("/logout", s.handleLogout)
r.Post("/forgot-password", s.handleForgotPassword)
r.Post("/reset-password", s.handleResetPassword)
r.Post("/invite-preview", s.handleInvitePreview)
r.Post("/accept-invite", s.handleAcceptInvite)
r.Post("/complete-set-password", s.handleCompleteSetPassword)
r.Group(func(r chi.Router) {
r.Use(s.RequireSession)
r.Get("/me", s.handleMe)
r.Patch("/me", s.handleUpdateProfile)
r.Post("/set-password", s.handleSetPassword)
r.Post("/change-password", s.handleChangePassword)
r.Post("/select-company", s.handleSelectCompany)
})
})
// Public sales contact (CSRF + rate limit; session optional for company/user attach).
r.With(s.RateLimitAuth).Post("/api/sales/contact", s.handleSalesContact)
r.Route("/api/admin", func(r chi.Router) {
r.Use(s.RequireSession)
// Non-prod only: user switch / impersonation (handlers also fail closed).
if !s.Config.IsProduction() {
r.Get("/dev/switchable-users", s.handleAdminDevListSwitchableUsers)
r.Post("/dev/stop-impersonate", s.handleAdminDevStopImpersonate)
r.Post("/users/{id}/impersonate", s.handleAdminDevImpersonate)
}
// Support desk: full admin OR support_staff (least privilege).
r.Group(func(r chi.Router) {
r.Use(s.RequireSupportDesk)
r.Get("/support/tickets", s.handleAdminListSupportTickets)
r.Get("/support/tickets/{id}", s.handleAdminGetSupportTicket)
r.Post("/support/tickets/{id}/messages", s.handleAdminReplySupportTicket)
r.Patch("/support/tickets/{id}", s.handleAdminUpdateSupportTicket)
r.Post("/support/tickets/{id}/claim", s.handleAdminClaimSupportTicket)
r.Post("/support/tickets/{id}/release", s.handleAdminReleaseSupportTicket)
r.Post("/support/tickets/{id}/ai-draft/approve", s.handleAdminApproveSupportAIDraft)
r.Post("/support/tickets/{id}/ai-draft/discard", s.handleAdminDiscardSupportAIDraft)
r.Get("/support/agents", s.handleAdminListSupportAgents)
})
// Full platform admin only (billing, settings, users, plan features).
r.Group(func(r chi.Router) {
r.Use(s.RequirePlatformAdmin)
r.Use(s.RateLimitAdminPlanFeatures)
r.Use(s.RateLimitAdminAnalytics)
r.Use(s.RateLimitAIProbes)
r.Get("/support/csat", s.handleAdminSupportCSATAggregate)
r.Get("/support/kb/articles", s.handleAdminListKBArticles)
r.Post("/support/kb/articles", s.handleAdminCreateKBArticle)
r.Get("/support/kb/articles/{id}", s.handleAdminGetKBArticle)
r.Patch("/support/kb/articles/{id}", s.handleAdminUpdateKBArticle)
r.Delete("/support/kb/articles/{id}", s.handleAdminDeleteKBArticle)
r.Get("/support/kb/categories", s.handleAdminListKBCategories)
r.Post("/support/kb/images", s.handleAdminUploadKBImage)
r.Get("/support/kb/images/{filename}", s.handleAdminGetKBImage)
r.Get("/support/templates", s.handleAdminListReplyTemplates)
r.Post("/support/templates", s.handleAdminCreateReplyTemplate)
r.Get("/support/templates/{id}", s.handleAdminGetReplyTemplate)
r.Patch("/support/templates/{id}", s.handleAdminUpdateReplyTemplate)
r.Delete("/support/templates/{id}", s.handleAdminDeleteReplyTemplate)
r.Get("/support/auto-config", s.handleAdminGetSupportAutoConfig)
r.Put("/support/auto-config", s.handleAdminPutSupportAutoConfig)
r.Get("/users", s.handleAdminListUsers)
r.Patch("/users/{id}/staff-role", s.handleAdminSetStaffRole)
r.Put("/support/agents/{id}", s.handleAdminSetSupportAgent)
r.Get("/staff", s.handleAdminListStaff)
if !s.Config.IsProduction() {
r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword)
}
r.Get("/companies", s.handleAdminListCompanies)
r.Get("/readiness", s.handleAdminReadiness)
r.Get("/diagnostics", s.handleAdminDiagnostics)
r.Get("/analytics", s.handleAdminAnalytics)
r.Get("/jobs", s.handleAdminListJobs)
r.Post("/jobs/stuck-cleanup", s.handleAdminStuckCleanup)
r.Get("/jobs/orphan-processed", s.handleAdminOrphanProcessedReport)
r.Post("/jobs/orphan-processed-cleanup", s.handleAdminOrphanProcessedCleanup)
r.Get("/stores/reconnect-needed", s.handleAdminListStoreReconnectGaps)
r.Get("/settings", s.handleGetAdminSettings)
r.Put("/settings", s.handlePutAdminSettings)
r.Post("/settings/mail/test", s.handleAdminTestMail)
r.Post("/settings/ai-roles/{role}/test", s.handleAdminTestAIRole)
r.Post("/settings/stripe/sync-credit-packs", s.handleAdminSyncStripeCreditPacks)
r.Get("/plans", s.handleListPlans)
r.Post("/plans", s.handleUpsertPlan)
r.Get("/plans/{planID}/features", s.handleAdminGetPlanFeatures)
r.Put("/plans/{planID}/features", s.handleAdminPutPlanFeatures)
r.Post("/plans/{planID}/features/enable-all", s.handleAdminEnableAllPlanFeatures)
r.Post("/plans/{planID}/features/disable-all", s.handleAdminDisableAllPlanFeatures)
r.Get("/feature-gates", s.handleAdminGetFeatureGates)
r.Put("/feature-gates", s.handleAdminPutFeatureGates)
r.Put("/feature-gates/sections/{section}", s.handleAdminPutFeatureGateSection)
r.Post("/plans/assign", s.handleAssignPlan)
r.Post("/credits", s.handleAddCredits)
r.Post("/billing/run-cycles", s.handleRunBillingCycles)
r.Post("/emails/set-password", s.handleAdminSendSetPasswordEmails)
r.Get("/sales/leads", s.handleAdminListSalesLeads)
r.Get("/sales/leads/{id}", s.handleAdminGetSalesLead)
r.Patch("/sales/leads/{id}", s.handleAdminUpdateSalesLead)
r.Post("/sales/leads/{id}/quotes", s.handleAdminCreateSalesQuote)
r.Post("/sales/quotes/{quoteID}/checkout", s.handleAdminPrepareSalesQuoteCheckout)
r.Post("/sales/quotes/{quoteID}/mark-sent", s.handleAdminMarkSalesQuoteSent)
})
})
r.Route("/api", func(r chi.Router) {
r.Use(s.RequireSession)
r.Use(s.RequireCompany)
r.Use(s.RateLimitMarketing)
r.Use(s.RateLimitAIProbes)
r.Use(s.RateLimitV1Process)
r.Get("/company", s.handleGetCompany)
r.Patch("/company", s.handleUpdateCompany)
r.Get("/company/settings", s.handleGetCompanySettings)
r.Put("/company/settings", s.handlePutCompanySettings)
r.Get("/brand", s.handleGetBrand)
r.Put("/brand", s.handlePutBrand)
r.Post("/brand/logo", s.handleUploadBrandLogo)
r.Get("/brand/logo/files/{filename}", s.handleGetBrandLogoFile)
r.Get("/team", s.handleListTeam)
r.Post("/team/invites", s.handleCreateInvite)
r.Get("/team/invites", s.handleListInvites)
r.Delete("/team/invites/{inviteID}", s.handleRevokeInvite)
r.Patch("/team/{userID}", s.handleUpdateMemberRole)
r.Delete("/team/{userID}", s.handleRemoveMember)
r.Get("/api-keys", s.handleListAPIKeys)
r.Post("/api-keys", s.handleCreateAPIKey)
r.Delete("/api-keys/{id}", s.handleRevokeAPIKey)
r.Get("/billing/credits", s.handleCreditsOverview)
r.Get("/billing/capabilities", s.handleGetCapabilities)
r.Get("/billing/usage", s.handleBillingUsage)
r.Get("/billing/plans", s.handleListPublicPlans)
r.Get("/billing/credit-packs", s.handleListCreditPacks)
r.Get("/billing/stripe", s.handleStripeStatus)
r.Post("/billing/checkout", s.handleStripeCheckout)
r.Post("/billing/portal", s.handleStripePortal)
r.Get("/field-groups", s.handleListFieldGroups)
r.Post("/field-groups", s.handleCreateFieldGroup)
r.Patch("/field-groups/{id}", s.handleUpdateFieldGroup)
r.Delete("/field-groups/{id}", s.handleDeleteFieldGroup)
r.Get("/standard-fields", s.handleListStandardFields)
r.Post("/standard-fields", s.handleCreateStandardField)
r.Post("/standard-fields/bulk-enable", s.handleBulkStandardFieldsEnabled)
r.Post("/standard-fields/enable-recommended", s.handleEnableRecommendedStandardFields)
r.Patch("/standard-fields/{id}", s.handleUpdateStandardField)
r.Delete("/standard-fields/{id}", s.handleDeleteStandardField)
r.Get("/structured-descriptions", s.handleListStructuredDescriptions)
r.Post("/structured-descriptions", s.handleCreateStructuredDescription)
r.Delete("/structured-descriptions/{id}", s.handleDeleteStructuredDescription)
r.Post("/vector-categories/create-index", s.handleVectorCreateIndex)
r.Post("/vector-categories/initialize", s.handleVectorInitialize)
r.Post("/vector-categories/search", s.handleVectorSearch)
r.Get("/categories", s.handleListCategories)
r.Post("/categories", s.handleCreateCategory)
r.Post("/categories/import", s.handleImportCSV)
r.Post("/categories/upload", s.handleImportCSV) // legacy/pixel alias
r.Get("/categories/{id}", s.handleGetCategory)
r.Patch("/categories/{id}", s.handleUpdateCategory)
r.Delete("/categories/{id}", s.handleDeleteCategory)
r.Patch("/categories/{id}/title-formula", s.handleUpdateTitleFormula)
r.Patch("/categories/{id}/description-formula", s.handleUpdateDescriptionFormula)
r.Patch("/categories/{id}/prompt", s.handleUpdateCategoryPrompt)
r.Get("/categories/{id}/attributes", s.handleListCategoryAttributes)
r.Put("/categories/{id}/attributes", s.handlePutCategoryAttributes)
r.Post("/categories/{id}/attributes", s.handleLinkCategoryAttribute)
r.Delete("/categories/{id}/attributes/{attributeID}", s.handleUnlinkCategoryAttribute)
r.Get("/variables", s.handleListVariables)
r.Post("/variables", s.handleCreateVariable)
r.Delete("/variables/{id}", s.handleDeleteVariable)
r.Get("/attributes", s.handleListAttributes)
r.Post("/attributes", s.handleCreateAttribute)
r.Post("/attributes/import", s.handleImportCSV)
r.Post("/attributes/upload", s.handleImportCSV) // legacy/pixel alias
r.Patch("/attributes/{id}", s.handleUpdateAttribute)
r.Delete("/attributes/{id}", s.handleDeleteAttribute)
r.Get("/files", s.handleListFiles)
r.Delete("/files/{id}", s.handleDeleteFile)
r.Get("/products", s.handleListProducts)
r.Get("/products/quality", s.handleListProductQuality)
r.Post("/products/import", s.handleImportCSV)
r.Post("/products/upload", s.handleImportCSV) // legacy/pixel alias
r.Post("/products/upload-eans", s.handleImportCSV) // legacy EAN CSV alias
r.Post("/products/reset", s.handleResetProducts)
r.Get("/products/{id}", s.handleGetProduct)
r.Patch("/products/{id}", s.handleUpdateProduct)
r.Get("/seo/recommendations", s.handleSEORecommendations)
r.Post("/seo/apply", s.handleSEOApply)
// Content calendar (seasonal export prep) — NOT email campaigns (/api/campaigns).
r.Get("/marketing/calendar", s.handleGetMarketingCalendar)
r.Post("/marketing/calendar/prepare", s.handlePrepareMarketingCalendar)
r.Get("/feeds", s.handleListFeeds)
r.Post("/feeds", s.handleCreateFeed)
r.Get("/feeds/{id}", s.handleGetFeed)
r.Patch("/feeds/{id}", s.handleUpdateFeed)
r.Delete("/feeds/{id}", s.handleDeleteFeed)
r.Post("/feeds/{id}/sync", s.handleSyncFeed)
r.Get("/feeds/{id}/sync-jobs", s.handleListSyncJobs)
r.Get("/feeds/{id}/sync-jobs/{jobID}", s.handleGetSyncJob)
r.Get("/feeds/{id}/mappings", s.handleGetFeedMappings)
r.Put("/feeds/{id}/mappings", s.handlePutFeedMappings)
r.Post("/feeds/{id}/extract-schema", s.handleExtractFeedSchema)
r.Post("/feeds/{id}/sync-process-sample", s.handleSyncAndProcessSample)
r.Get("/export-feeds", s.handleListExportFeeds)
r.Post("/export-feeds", s.handleCreateExportFeed)
r.Get("/export-feeds/{id}", s.handleGetExportFeed)
r.Patch("/export-feeds/{id}", s.handleUpdateExportFeed)
r.Put("/export-feeds/{id}/template", s.handleUpdateExportFeedTemplate)
r.Delete("/export-feeds/{id}", s.handleDeleteExportFeed)
r.Post("/export-feeds/{id}/rotate-token", s.handleRotateExportFeedPublicToken)
r.Post("/export-feeds/{id}/generate", s.handleGenerateExportFeed)
r.Post("/export-feeds/{id}/export-products", s.handleExportSelectedProducts)
r.Post("/processing/jobs", s.handleStartProcessingJob)
r.Get("/processing/jobs", s.handleListProcessingJobs)
r.Get("/processing/jobs/{id}", s.handleGetProcessingJob)
r.Post("/processing/jobs/{id}/cancel", s.handleCancelProcessingJob)
r.Post("/processing/jobs/{id}/terminate", s.handleCancelProcessingJob) // pixel naming alias
r.Post("/processing/jobs/{id}/retry", s.handleRetryProcessingJob)
r.Get("/woocommerce", s.handleGetWooConfig)
r.Put("/woocommerce", s.handleUpdateWooConfig)
r.Put("/woocommerce/maps", s.handleUpdateWooMaps)
r.Put("/woocommerce/schedule", s.handleUpdateWooSchedule)
r.Get("/woocommerce/remote-maps", s.handleFetchWooRemoteMaps)
r.Post("/woocommerce/test", s.handleTestWoo)
r.Post("/woocommerce/sync", s.handleSyncWoo)
r.Post("/woocommerce/sync-orders", s.handleSyncWooOrders)
r.Post("/woocommerce/sync-reviews", s.handleSyncWooReviews)
r.Get("/woocommerce/orders", s.handleListWooOrders)
r.Get("/woocommerce/reviews", s.handleListWooReviews)
r.Post("/woocommerce/audience", s.handleWooAudience)
r.Get("/shopify", s.handleGetShopifyConfig)
r.Put("/shopify", s.handleUpdateShopifyConfig)
r.Put("/shopify/schedule", s.handleUpdateShopifySchedule)
r.Post("/shopify/test", s.handleTestShopify)
r.Post("/shopify/sync", s.handleSyncShopify)
r.Post("/shopify/sync-orders", s.handleSyncShopifyOrders)
r.Get("/shopify/orders", s.handleListShopifyOrders)
r.Get("/support/tickets", s.handleListSupportTickets)
r.Post("/support/tickets", s.handleCreateSupportTicket)
r.Get("/support/tickets/{id}", s.handleGetSupportTicket)
r.Post("/support/tickets/{id}/messages", s.handleReplySupportTicket)
r.Post("/support/tickets/{id}/csat", s.handleSubmitSupportCSAT)
r.Get("/support/notifications", s.handleListNotifications)
r.Post("/support/notifications/read-all", s.handleMarkAllNotificationsRead)
r.Post("/support/notifications/{id}/read", s.handleMarkNotificationRead)
r.Get("/campaigns/templates", s.handleListCampaignTemplates)
r.Get("/campaigns", s.handleListCampaigns)
r.Post("/campaigns", s.handleCreateCampaign)
r.Get("/campaigns/{id}", s.handleGetCampaign)
r.Patch("/campaigns/{id}", s.handleUpdateCampaign)
r.Delete("/campaigns/{id}", s.handleDeleteCampaign)
r.Post("/campaigns/{id}/generate", s.handleGenerateCampaign)
r.Post("/campaigns/{id}/send-test", s.handleSendTestCampaign)
r.Post("/campaigns/{id}/schedule", s.handleScheduleCampaign)
r.Post("/campaigns/{id}/send", s.handleSendCampaign)
r.Get("/integrations/email", s.handleGetEmailIntegration)
r.Put("/integrations/email", s.handlePutEmailIntegration)
r.Patch("/integrations/email", s.handlePutEmailIntegration)
r.Post("/integrations/email/verify", s.handleVerifyEmailIntegration)
r.Post("/integrations/email/test", s.handleTestEmailIntegration)
r.Post("/email/send", s.handleSendEmail)
r.Get("/integrations/ai", s.handleGetAIIntegration)
r.Put("/integrations/ai", s.handlePutAIIntegration)
r.Patch("/integrations/ai", s.handlePutAIIntegration)
r.Post("/integrations/ai/test", s.handleTestAIIntegration)
r.Get("/integrations/ai/prompts", s.handleGetAIPrompts)
r.Put("/integrations/ai/prompts", s.handlePutAIPrompts)
r.Patch("/integrations/ai/prompts", s.handlePutAIPrompts)
})
})
return r
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
@@ -0,0 +1,188 @@
package httpapi
import (
"net/http"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/shopify"
)
func (s *Server) handleGetShopifyConfig(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
cfg, err := s.Shopify.GetConfig(r.Context(), cid)
if err != nil {
JSON(w, http.StatusOK, map[string]any{
"shop_domain": "", "api_version": "2024-10", "is_enabled": false,
"configured": false, "has_credentials": false, "reviews_supported": false,
})
return
}
JSON(w, http.StatusOK, cfg)
}
func (s *Server) handleUpdateShopifyConfig(w http.ResponseWriter, r *http.Request) {
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
if !s.requireFeatures(w, r, "stores.shopify") {
return
}
cid, _ := CompanyIDFromContext(r.Context())
var body struct {
ShopDomain string `json:"shop_domain"`
AccessToken string `json:"access_token"`
APIVersion string `json:"api_version"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
IsEnabled bool `json:"is_enabled"`
DryRun bool `json:"dry_run"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
cfg, err := s.Shopify.UpdateConfig(r.Context(), cid, shopify.UpdateInput{
ShopDomain: body.ShopDomain,
AccessToken: body.AccessToken,
APIVersion: body.APIVersion,
ClientID: body.ClientID,
ClientSecret: body.ClientSecret,
IsEnabled: body.IsEnabled,
DryRun: body.DryRun,
})
if msg, ok := shopify.ClientError(err); ok {
Error(w, http.StatusBadRequest, msg)
return
}
if err != nil {
LogAndError(w, http.StatusInternalServerError, "update failed", err)
return
}
JSON(w, http.StatusOK, cfg)
}
func (s *Server) handleTestShopify(w http.ResponseWriter, r *http.Request) {
if !s.requireFeatures(w, r, "stores.shopify") {
return
}
cid, _ := CompanyIDFromContext(r.Context())
result, err := s.Shopify.TestConnection(r.Context(), cid)
if result == nil {
result = map[string]any{"status": "failed", "message": "connection failed"}
}
if err != nil {
JSON(w, http.StatusOK, result)
return
}
JSON(w, http.StatusOK, result)
}
func (s *Server) handleSyncShopify(w http.ResponseWriter, r *http.Request) {
if !s.requireFeatures(w, r, "stores.shopify") {
return
}
cid, _ := CompanyIDFromContext(r.Context())
var scope shopify.ProductSyncScope
if err := DecodeJSONOptional(r, &scope); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
result, err := s.Shopify.EnqueueSync(r.Context(), cid, scope)
if msg, ok := shopify.ClientError(err); ok {
Error(w, http.StatusBadRequest, msg)
return
}
if err != nil {
LogAndError(w, http.StatusInternalServerError, "sync enqueue failed", err)
return
}
JSON(w, http.StatusAccepted, result)
}
func (s *Server) handleSyncShopifyOrders(w http.ResponseWriter, r *http.Request) {
if !s.requireFeatures(w, r, "stores.shopify") {
return
}
cid, _ := CompanyIDFromContext(r.Context())
result, err := s.Shopify.EnqueueOrdersSync(r.Context(), cid)
if msg, ok := shopify.ClientError(err); ok {
Error(w, http.StatusBadRequest, msg)
return
}
if err != nil {
LogAndError(w, http.StatusInternalServerError, "orders sync enqueue failed", err)
return
}
JSON(w, http.StatusAccepted, result)
}
func (s *Server) handleListShopifyOrders(w http.ResponseWriter, r *http.Request) {
if s.Shopify == nil {
JSON(w, http.StatusOK, map[string]any{"orders": []any{}, "total": 0, "limit": 50, "offset": 0})
return
}
cid, _ := CompanyIDFromContext(r.Context())
limit, offset := ParseLimitOffset(r)
f := shopify.OrderListFilter{
Status: strings.TrimSpace(r.URL.Query().Get("status")),
Email: strings.TrimSpace(r.URL.Query().Get("email")),
Limit: limit,
Offset: offset,
}
if since := strings.TrimSpace(r.URL.Query().Get("since")); since != "" {
if t, err := time.Parse(time.RFC3339, since); err == nil {
f.Since = &t
} else {
Error(w, http.StatusBadRequest, "invalid since (use RFC3339)")
return
}
}
items, total, err := s.Shopify.ListOrders(r.Context(), cid, f)
if err != nil {
JSON(w, http.StatusOK, map[string]any{
"orders": []any{},
"total": 0,
"limit": limit,
"offset": offset,
})
return
}
if items == nil {
items = []shopify.OrderRow{}
}
JSON(w, http.StatusOK, map[string]any{
"orders": items,
"total": total,
"limit": limit,
"offset": offset,
})
}
func (s *Server) handleUpdateShopifySchedule(w http.ResponseWriter, r *http.Request) {
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
if !s.requireFeatures(w, r, "stores.shopify") {
return
}
cid, _ := CompanyIDFromContext(r.Context())
var body struct {
ScheduleIntervalHours int `json:"schedule_interval_hours"`
SchedulePaused bool `json:"schedule_paused"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
cfg, err := s.Shopify.UpdateSchedule(r.Context(), cid, body.ScheduleIntervalHours, body.SchedulePaused)
if msg, ok := shopify.ClientError(err); ok {
Error(w, http.StatusBadRequest, msg)
return
}
if err != nil {
LogAndError(w, http.StatusInternalServerError, "update schedule failed", err)
return
}
JSON(w, http.StatusOK, cfg)
}
@@ -0,0 +1,256 @@
package httpapi
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/support"
"github.com/google/uuid"
)
func TestRequireSupportDeskForbiddenAndAllow(t *testing.T) {
t.Parallel()
uid := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
t.Run("unauthorized", func(t *testing.T) {
t.Parallel()
s := &Server{}
called := false
h := s.RequireSupportDesk(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/api/admin/support/tickets", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if called {
t.Fatal("handler must not run without session user")
}
})
t.Run("member_forbidden", func(t *testing.T) {
t.Parallel()
s := &Server{
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
return auth.StaffAccess{}, nil
},
}
called := false
h := s.RequireSupportDesk(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodGet, "/api/admin/support/tickets", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if called {
t.Fatal("handler must not run for non-staff")
}
})
t.Run("support_staff_allowed", func(t *testing.T) {
t.Parallel()
s := &Server{
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
return auth.ResolveStaffAccess(false, auth.StaffRoleSupportStaff), nil
},
}
called := false
h := s.RequireSupportDesk(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
called = true
access, ok := StaffAccessFromContext(req.Context())
if !ok || !access.SupportDesk || !access.IsSupportOnly {
t.Fatalf("expected support-only access in context, got ok=%v %+v", ok, access)
}
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodGet, "/api/admin/support/tickets", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
if !called {
t.Fatal("handler must run for support_staff")
}
})
}
func TestSupportStaffForbiddenOnPlanFeatures(t *testing.T) {
t.Parallel()
uid := uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc")
s := &Server{
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
return auth.ResolveStaffAccess(true, auth.StaffRoleSupportStaff), nil
},
}
cases := []struct {
name string
body string
}{
{name: "get_plan_features", body: ""},
{name: "put_plan_features", body: `{"features":{}}`},
{name: "enable_all", body: ""},
{name: "disable_all", body: ""},
{name: "get_gates", body: ""},
{name: "put_gates", body: `{"features":{}}`},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
called := false
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodPut, "/api/admin/plans/1/features", bytes.NewBufferString(tc.body)).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d body=%s, want 403", rec.Code, rec.Body.String())
}
if called {
t.Fatal("plan feature handler must not run for support_staff")
}
})
}
}
func TestMemberForbiddenOnAdminSupportAndPlanRoutes(t *testing.T) {
t.Parallel()
uid := uuid.MustParse("dddddddd-dddd-dddd-dddd-dddddddddddd")
s := &Server{
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
return auth.StaffAccess{}, nil
},
}
t.Run("support_desk", func(t *testing.T) {
t.Parallel()
h := s.RequireSupportDesk(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodGet, "/api/admin/support/tickets", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
})
t.Run("platform_admin", func(t *testing.T) {
t.Parallel()
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodGet, "/api/admin/plans/1/features", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
})
}
func TestUserReplyMassAssignmentRejected(t *testing.T) {
t.Parallel()
// UserReplyInput only accepts "body"; DisallowUnknownFields rejects status / is_internal_note.
var dst support.UserReplyInput
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"body":"hi","is_internal_note":true,"status":"closed"}`))
err := DecodeJSON(req, &dst)
if err == nil {
t.Fatal("expected DecodeJSON to reject mass-assignment fields on UserReplyInput")
}
}
func TestRedactForLog(t *testing.T) {
t.Parallel()
in := "smtp dial failed password=SuperSecret123 api_key=sk_live_abc token:xyz"
out := redactForLog(in)
if strings.Contains(out, "SuperSecret123") || strings.Contains(out, "sk_live_abc") || strings.Contains(out, ":xyz") {
t.Fatalf("secrets leaked in log: %s", out)
}
if !strings.Contains(out, "[REDACTED]") {
t.Fatalf("expected redaction markers, got %s", out)
}
}
func TestStaffMayAccessTicket(t *testing.T) {
t.Parallel()
s := &Server{}
actor := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
other := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
t.Run("full_admin_sees_all", func(t *testing.T) {
t.Parallel()
ctx := withStaffAccess(context.Background(), auth.ResolveStaffAccess(true, ""))
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
ticket := support.Ticket{AssigneeAdminUserID: &other}
if !s.staffMayAccessTicket(req, actor, ticket) {
t.Fatal("full admin must see assigned tickets")
}
})
t.Run("support_staff_own_or_unassigned", func(t *testing.T) {
t.Parallel()
ctx := withStaffAccess(context.Background(), auth.ResolveStaffAccess(false, auth.StaffRoleSupportStaff))
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
if !s.staffMayAccessTicket(req, actor, support.Ticket{Status: "open"}) {
t.Fatal("unassigned open must be visible")
}
if s.staffMayAccessTicket(req, actor, support.Ticket{Status: "resolved"}) {
t.Fatal("unassigned resolved must be hidden from claim queue")
}
own := actor
if !s.staffMayAccessTicket(req, actor, support.Ticket{AssigneeAdminUserID: &own}) {
t.Fatal("own assignment must be visible")
}
if s.staffMayAccessTicket(req, actor, support.Ticket{AssigneeAdminUserID: &other}) {
t.Fatal("other assignee must be hidden")
}
})
}
func TestRequirePlatformAdminExcludesSupportStaff(t *testing.T) {
t.Parallel()
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
return auth.ResolveStaffAccess(true, auth.StaffRoleSupportStaff), nil
},
}
called := false
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
ctx := context.WithValue(context.Background(), ctxUserID, uid)
req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if called {
t.Fatal("full admin routes must reject support_staff even with is_platform_admin")
}
}
@@ -0,0 +1,283 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func (s *Server) handleListFieldGroups(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
items, err := s.Catalog.ListFieldGroups(r.Context(), cid)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
limit, offset := ParseLimitOffsetMax(r, maxTreePageLimit)
page, total := pageSlice(items, limit, offset)
JSON(w, http.StatusOK, map[string]any{"groups": page, "total": total, "limit": limit, "offset": offset})
}
func (s *Server) handleCreateFieldGroup(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var body map[string]any
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
name, _ := body["name"].(string)
var desc *string
if v, ok := body["description"]; ok {
if v == nil {
empty := ""
desc = &empty
} else if str, ok := v.(string); ok {
desc = &str
}
}
order := 0
if v, ok := body["order"].(float64); ok {
order = int(v)
}
item, err := s.Catalog.CreateFieldGroup(r.Context(), cid, name, desc, order)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not create field group", err, catalog.ClientError)
return
}
JSON(w, http.StatusCreated, item)
}
func (s *Server) handleUpdateFieldGroup(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body map[string]any
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Catalog.UpdateFieldGroup(r.Context(), cid, id, body)
if errors.Is(err, catalog.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if errors.Is(err, catalog.ErrSystemImmutable) {
Error(w, http.StatusForbidden, err.Error())
return
}
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update field group", err, catalog.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleDeleteFieldGroup(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
err = s.Catalog.DeleteFieldGroup(r.Context(), cid, id)
if errors.Is(err, catalog.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if errors.Is(err, catalog.ErrSystemImmutable) {
Error(w, http.StatusForbidden, err.Error())
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "delete failed")
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleListStandardFields(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
enabledOnly := false
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("enabled"))) {
case "1", "true", "yes":
enabledOnly = true
}
items, err := s.Catalog.ListStandardFields(r.Context(), cid, enabledOnly)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
limit, offset := ParseLimitOffsetMax(r, maxTreePageLimit)
page, total := pageSlice(items, limit, offset)
JSON(w, http.StatusOK, map[string]any{"fields": page, "total": total, "limit": limit, "offset": offset})
}
func (s *Server) handleCreateStandardField(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var body map[string]any
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Catalog.CreateStandardField(r.Context(), cid, body)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not create standard field", err, catalog.ClientError)
return
}
JSON(w, http.StatusCreated, item)
}
func (s *Server) handleUpdateStandardField(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body map[string]any
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Catalog.UpdateStandardField(r.Context(), cid, id, body)
if errors.Is(err, catalog.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if errors.Is(err, catalog.ErrSystemImmutable) {
Error(w, http.StatusForbidden, err.Error())
return
}
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update standard field", err, catalog.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleDeleteStandardField(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
err = s.Catalog.DeleteStandardField(r.Context(), cid, id)
if errors.Is(err, catalog.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if errors.Is(err, catalog.ErrSystemImmutable) {
Error(w, http.StatusForbidden, err.Error())
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "delete failed")
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleBulkStandardFieldsEnabled(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var body struct {
IDs []string `json:"ids"`
Enabled *bool `json:"enabled"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if body.Enabled == nil {
Error(w, http.StatusBadRequest, "enabled required")
return
}
ids := make([]uuid.UUID, 0, len(body.IDs))
for _, raw := range body.IDs {
id, err := uuid.Parse(strings.TrimSpace(raw))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
ids = append(ids, id)
}
n, err := s.Catalog.BulkSetStandardFieldsEnabled(r.Context(), cid, ids, *body.Enabled)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update standard fields", err, catalog.ClientError)
return
}
JSON(w, http.StatusOK, map[string]any{"updated": n, "enabled": *body.Enabled})
}
func (s *Server) handleEnableRecommendedStandardFields(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
items, err := s.Catalog.EnableRecommendedEcommerce(r.Context(), cid)
if err != nil {
LogAndError(w, http.StatusInternalServerError, "enable recommended fields failed", err)
return
}
JSON(w, http.StatusOK, map[string]any{"fields": items, "status": "ok"})
}
func (s *Server) handleListStructuredDescriptions(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
items, err := s.Catalog.ListStructuredDescriptions(r.Context(), cid)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
limit, offset := ParseLimitOffsetMax(r, maxTreePageLimit)
page, total := pageSlice(items, limit, offset)
JSON(w, http.StatusOK, map[string]any{"fields": page, "total": total, "limit": limit, "offset": offset})
}
func (s *Server) handleCreateStructuredDescription(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var body map[string]any
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
fieldKey := ""
if v, ok := body["field_key"].(string); ok {
fieldKey = v
} else if v, ok := body["fieldKey"].(string); ok {
fieldKey = v
}
typ := "text"
if v, ok := body["type"].(string); ok && v != "" {
typ = v
}
item, err := s.Catalog.CreateStructuredDescription(r.Context(), cid, fieldKey, typ)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not create structured description", err, catalog.ClientError)
return
}
JSON(w, http.StatusCreated, item)
}
func (s *Server) handleDeleteStructuredDescription(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
err = s.Catalog.DeleteStructuredDescription(r.Context(), cid, id)
if errors.Is(err, catalog.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "delete failed")
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
@@ -0,0 +1,77 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/shopify"
"github.com/descrybe/descrybe-v2/apps/api/internal/woocommerce"
"github.com/google/uuid"
)
func TestHandleUpdateShopifyScheduleRejectsInvalidJSON(t *testing.T) {
t.Parallel()
s := &Server{Shopify: &shopify.Service{}}
ctx := context.WithValue(context.Background(), ctxCompanyID, uuid.MustParse("11111111-1111-1111-1111-111111111111"))
ctx = context.WithValue(ctx, ctxRole, "admin")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/shopify/schedule", strings.NewReader(`{bad`))
req = req.WithContext(ctx)
s.handleUpdateShopifySchedule(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleUpdateShopifyScheduleRejectsInvalidInterval(t *testing.T) {
t.Parallel()
s := &Server{Shopify: &shopify.Service{}} // Pool nil — interval check runs before DB
ctx := context.WithValue(context.Background(), ctxCompanyID, uuid.MustParse("11111111-1111-1111-1111-111111111111"))
ctx = context.WithValue(ctx, ctxRole, "admin")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/shopify/schedule", strings.NewReader(`{"schedule_interval_hours":999,"schedule_paused":false}`))
req = req.WithContext(ctx)
s.handleUpdateShopifySchedule(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleSyncShopifyRejectsInvalidJSON(t *testing.T) {
t.Parallel()
s := &Server{} // Shopify unused — DecodeJSONOptional fails first
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/shopify/sync", strings.NewReader(`{"status":`))
s.handleSyncShopify(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleUpdateWooScheduleRejectsInvalidInterval(t *testing.T) {
t.Parallel()
s := &Server{Woo: &woocommerce.Service{}}
ctx := context.WithValue(context.Background(), ctxCompanyID, uuid.MustParse("11111111-1111-1111-1111-111111111111"))
ctx = context.WithValue(ctx, ctxRole, "admin")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/woocommerce/schedule", strings.NewReader(`{"schedule_interval_hours":-2,"schedule_paused":true}`))
req = req.WithContext(ctx)
s.handleUpdateWooSchedule(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleSyncWooRejectsInvalidJSON(t *testing.T) {
t.Parallel()
s := &Server{}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/woocommerce/sync", strings.NewReader(`[`))
s.handleSyncWoo(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
@@ -0,0 +1,106 @@
package httpapi
import (
"errors"
"io"
"net/http"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
)
func (s *Server) stripeSvc() *billing.StripeService {
if s.Stripe != nil {
return s.Stripe
}
s.Stripe = &billing.StripeService{
Pool: s.Pool,
Billing: s.Billing,
Cfg: billing.StripeConfig{
SecretKey: s.Config.StripeSecretKey,
WebhookSecret: s.Config.StripeWebhookSecret,
WebOrigin: s.Config.WebOrigin,
PublicAPIURL: s.Config.PublicAPIURL,
PriceIDs: s.Config.StripePriceIDs,
ForceMock: s.Config.StripeMock,
},
}
return s.Stripe
}
func (s *Server) handleStripeStatus(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
st, err := s.stripeSvc().Status(r.Context(), cid)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to load stripe status")
return
}
JSON(w, http.StatusOK, st)
}
func (s *Server) handleStripeCheckout(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
uid, _ := UserIDFromContext(r.Context())
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
return
}
var body billing.CheckoutRequest
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
email, name := "", ""
_ = s.Pool.QueryRow(r.Context(), `SELECT email, COALESCE(name, '') FROM users WHERE id = $1`, uid).Scan(&email, &name)
var companyName string
_ = s.Pool.QueryRow(r.Context(), `SELECT name FROM companies WHERE id = $1`, cid).Scan(&companyName)
res, err := s.stripeSvc().CreateCheckoutSession(r.Context(), cid, email, companyName, body)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "checkout failed", err, billing.ClientError)
return
}
JSON(w, http.StatusOK, res)
}
func (s *Server) handleStripePortal(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
return
}
res, err := s.stripeSvc().CreatePortalSession(r.Context(), cid)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "portal session failed", err, billing.ClientError)
return
}
JSON(w, http.StatusOK, res)
}
// handleStripeWebhook is public (no session/CSRF). Signature verified when STRIPE_WEBHOOK_SECRET is set.
func (s *Server) handleStripeWebhook(w http.ResponseWriter, r *http.Request) {
const maxBody = 1 << 20 // 1 MiB
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1))
if err != nil {
Error(w, http.StatusBadRequest, "failed to read body")
return
}
if len(body) > maxBody {
Error(w, http.StatusRequestEntityTooLarge, "body too large")
return
}
sig := r.Header.Get("Stripe-Signature")
if err := s.stripeSvc().HandleWebhook(r.Context(), body, sig); err != nil {
switch {
case errors.Is(err, billing.ErrStripeBadSignature):
Error(w, http.StatusBadRequest, "invalid signature")
case errors.Is(err, billing.ErrStripeNotConfigured):
Error(w, http.StatusServiceUnavailable, "stripe webhooks not configured")
default:
// Avoid leaking internal apply/DB details to an unauthenticated caller.
LogAndError(w, http.StatusBadRequest, "webhook processing failed", err)
}
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
@@ -0,0 +1,129 @@
package httpapi
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/google/uuid"
)
func TestHandleStripePortalMockLocal(t *testing.T) {
t.Parallel()
s := &Server{
Config: config.Config{WebOrigin: "http://localhost:5174", StripeMock: true},
Stripe: &billing.StripeService{
Cfg: billing.StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"},
},
}
cid := uuid.New()
uid := uuid.New()
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "admin")
req := httptest.NewRequest(http.MethodPost, "/api/billing/portal", nil)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
s.handleStripePortal(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body billing.PortalResult
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if !body.Mock || body.URL != "http://localhost:5174/billing?portal=mock" {
t.Fatalf("got %#v", body)
}
}
func TestHandleStripeWebhookRejectsBadSignature(t *testing.T) {
t.Parallel()
secret := "whsec_handler_test"
s := &Server{
Stripe: &billing.StripeService{
Cfg: billing.StripeConfig{ForceMock: true, WebhookSecret: secret},
},
}
payload := []byte(`{"id":"evt_bad","type":"ping"}`)
req := httptest.NewRequest(http.MethodPost, "/api/billing/webhook", bytes.NewReader(payload))
req.Header.Set("Stripe-Signature", "t=1,v1=deadbeef")
rec := httptest.NewRecorder()
s.handleStripeWebhook(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s want 400", rec.Code, rec.Body.String())
}
}
func TestHandleStripeWebhookUnsignedWithoutSecretNeedsForceMock(t *testing.T) {
t.Parallel()
// Misconfigured live (no webhook secret, no ForceMock) must 503 — never process unsigned.
s := &Server{
Stripe: &billing.StripeService{Cfg: billing.StripeConfig{}},
}
req := httptest.NewRequest(http.MethodPost, "/api/billing/webhook", bytes.NewReader([]byte(`{"id":"evt_x","type":"ping"}`)))
rec := httptest.NewRecorder()
s.handleStripeWebhook(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d body=%s want 503", rec.Code, rec.Body.String())
}
}
func TestHandleStripeCheckoutMockRequiresAdmin(t *testing.T) {
t.Parallel()
s := &Server{
Config: config.Config{StripeMock: true},
Stripe: &billing.StripeService{Cfg: billing.StripeConfig{ForceMock: true}},
}
cid := uuid.New()
uid := uuid.New()
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "member")
req := httptest.NewRequest(http.MethodPost, "/api/billing/checkout", bytes.NewBufferString(`{"plan":"starter","term":"monthly"}`))
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
s.handleStripeCheckout(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d want 403", rec.Code)
}
}
func signHandlerStripePayload(t *testing.T, secret string, payload []byte) string {
t.Helper()
ts := time.Now().Unix()
mac := hmac.New(sha256.New, []byte(secret))
_, _ = fmt.Fprintf(mac, "%d.", ts)
_, _ = mac.Write(payload)
return fmt.Sprintf("t=%d,v1=%s", ts, hex.EncodeToString(mac.Sum(nil)))
}
func TestHandleStripeWebhookValidSignatureStillVerifiedUnderMock(t *testing.T) {
t.Parallel()
secret := "whsec_handler_ok"
// No Pool: claim fails closed after signature passes — proves verify runs before apply.
s := &Server{
Stripe: &billing.StripeService{
Cfg: billing.StripeConfig{ForceMock: true, WebhookSecret: secret},
},
}
payload := []byte(`{"id":"evt_ok","type":"ping"}`)
req := httptest.NewRequest(http.MethodPost, "/api/billing/webhook", bytes.NewReader(payload))
req.Header.Set("Stripe-Signature", signHandlerStripePayload(t, secret, payload))
rec := httptest.NewRecorder()
s.handleStripeWebhook(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s — expect apply/store failure after verify", rec.Code, rec.Body.String())
}
}
@@ -0,0 +1,51 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
)
// supportAuthPaths are the session-gated support CRUD / notification probes.
// Skip the suite when none are mounted yet (sibling HTTP wiring in progress).
var supportAuthPaths = []struct {
method string
path string
}{
{http.MethodGet, "/api/support/tickets"},
{http.MethodPost, "/api/support/tickets"},
{http.MethodGet, "/api/support/notifications"},
{http.MethodGet, "/api/admin/support/tickets"},
{http.MethodGet, "/api/admin/support/csat"},
{http.MethodGet, "/api/admin/support/kb/articles"},
{http.MethodGet, "/api/admin/support/kb/categories"},
{http.MethodGet, "/api/admin/support/templates"},
{http.MethodGet, "/api/admin/support/auto-config"},
}
// TestRouterSupportTicketCRUDAuthRequiresSession asserts unauthenticated callers
// get 401 (not 200) on support routes when those routes are mounted.
func TestRouterSupportTicketCRUDAuthRequiresSession(t *testing.T) {
t.Parallel()
s := testAPIServer()
h := s.Router()
mounted := 0
for _, tc := range supportAuthPaths {
rec := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, nil)
h.ServeHTTP(rec, req)
switch rec.Code {
case http.StatusNotFound:
continue
case http.StatusUnauthorized, http.StatusForbidden:
mounted++
default:
t.Fatalf("%s %s status=%d want 401/403 when mounted (body=%s)",
tc.method, tc.path, rec.Code, rec.Body.String())
}
}
if mounted == 0 {
t.Skip("support ticket HTTP routes not mounted yet")
}
}
@@ -0,0 +1,43 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestRouterSupportCSATAuthRequiresSession(t *testing.T) {
t.Parallel()
s := testAPIServer()
h := s.Router()
cases := []struct {
method string
path string
}{
{http.MethodPost, "/api/support/tickets/00000000-0000-0000-0000-000000000001/csat"},
{http.MethodGet, "/api/admin/support/csat"},
}
mounted := 0
for _, tc := range cases {
rec := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(`{"score":5}`))
if tc.method == http.MethodPost {
req.Header.Set("Content-Type", "application/json")
}
h.ServeHTTP(rec, req)
switch rec.Code {
case http.StatusNotFound:
continue
case http.StatusUnauthorized, http.StatusForbidden:
mounted++
default:
t.Fatalf("%s %s status=%d want 401/403 (body=%s)",
tc.method, tc.path, rec.Code, rec.Body.String())
}
}
if mounted == 0 {
t.Skip("csat routes not mounted yet")
}
}
@@ -0,0 +1,99 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/support"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func (s *Server) handleSubmitSupportCSAT(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
cid, ok := CompanyIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body support.CSATInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Support.SubmitCSAT(r.Context(), cid, uid, id, body)
if errors.Is(err, support.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if errors.Is(err, support.ErrAlreadyRated) {
Error(w, http.StatusConflict, "already rated")
return
}
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not submit rating", err, support.ClientError)
return
}
JSON(w, http.StatusCreated, item)
}
func (s *Server) handleAdminSupportCSATAggregate(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
JSON(w, http.StatusOK, support.CSATAggregate{
Total: 0,
Average: 0,
Distribution: map[string]int64{"1": 0, "2": 0, "3": 0, "4": 0, "5": 0},
})
return
}
var from, to *time.Time
if raw := strings.TrimSpace(r.URL.Query().Get("from")); raw != "" {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
Error(w, http.StatusBadRequest, "invalid from (use RFC3339)")
return
}
t = t.UTC()
from = &t
}
if raw := strings.TrimSpace(r.URL.Query().Get("to")); raw != "" {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
Error(w, http.StatusBadRequest, "invalid to (use RFC3339)")
return
}
t = t.UTC()
to = &t
}
agg, err := s.Support.AggregateCSAT(r.Context(), from, to)
if err != nil {
if support.IsMissingRelation(err) {
JSON(w, http.StatusOK, support.CSATAggregate{
Total: 0,
Average: 0,
Distribution: map[string]int64{"1": 0, "2": 0, "3": 0, "4": 0, "5": 0},
From: from,
To: to,
})
return
}
LogAndError(w, http.StatusInternalServerError, "could not load csat aggregate", err)
return
}
JSON(w, http.StatusOK, agg)
}
@@ -0,0 +1,668 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/support"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func (s *Server) handleListSupportTickets(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
JSON(w, http.StatusOK, map[string]any{"tickets": []any{}, "total": 0, "limit": 0, "offset": 0})
return
}
cid, ok := CompanyIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
limit, offset := ParseLimitOffset(r)
status := strings.TrimSpace(r.URL.Query().Get("status"))
items, total, err := s.Support.ListForUser(r.Context(), cid, uid, status, limit, offset)
if err != nil {
if support.IsMissingRelation(err) {
JSON(w, http.StatusOK, map[string]any{"tickets": []any{}, "total": 0, "limit": limit, "offset": offset})
return
}
ClientOrLog(w, http.StatusBadRequest, "could not list tickets", err, support.ClientError)
return
}
if items == nil {
items = []support.Ticket{}
}
JSON(w, http.StatusOK, map[string]any{"tickets": items, "total": total, "limit": limit, "offset": offset})
}
func (s *Server) handleCreateSupportTicket(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
cid, ok := CompanyIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
var body support.CreateInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Support.Create(r.Context(), cid, uid, body)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not create ticket", err, support.ClientError)
return
}
// Stage A FAQ match (sync). Never awaits LLM — agent 4 owns AI fallback.
if updated, _, matchErr := s.Support.MaybeAutoReplyOnCreate(r.Context(), item); matchErr == nil {
item = updated
}
JSON(w, http.StatusCreated, item)
}
func (s *Server) handleGetSupportTicket(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
cid, ok := CompanyIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Support.GetForUser(r.Context(), cid, uid, id)
if errors.Is(err, support.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if err != nil {
LogAndError(w, http.StatusInternalServerError, "get failed", err)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleReplySupportTicket(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
cid, ok := CompanyIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
// Mass-assignment guard: customers cannot set is_internal_note / status.
var body support.UserReplyInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Support.ReplyAsUser(r.Context(), cid, uid, id, support.ReplyInput{Body: body.Body})
if errors.Is(err, support.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not reply", err, support.ClientError)
return
}
if updated, _, matchErr := s.Support.MaybeAutoReplyOnCustomerReply(r.Context(), item); matchErr == nil {
item = updated
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleAdminListSupportTickets(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
JSON(w, http.StatusOK, map[string]any{"tickets": []any{}, "total": 0, "limit": 0, "offset": 0})
return
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
access, _ := StaffAccessFromContext(r.Context())
limit, offset := ParseLimitOffset(r)
f := support.ListFilter{
Status: strings.TrimSpace(r.URL.Query().Get("status")),
Search: QuerySearch(r),
Scope: strings.TrimSpace(r.URL.Query().Get("scope")),
Flag: strings.ToLower(strings.TrimSpace(r.URL.Query().Get("flag"))),
ActorID: uid,
FullAdmin: access.FullAdmin,
}
if f.Flag != "" && f.Flag != support.FlagNeedsHuman && f.Flag != support.FlagAIDraft {
Error(w, http.StatusBadRequest, "invalid flag")
return
}
if raw := strings.TrimSpace(r.URL.Query().Get("company_id")); raw != "" {
cid, err := uuid.Parse(raw)
if err != nil {
Error(w, http.StatusBadRequest, "invalid company_id")
return
}
f.CompanyID = &cid
}
if access.FullAdmin {
if raw := strings.TrimSpace(r.URL.Query().Get("assignee_id")); raw != "" {
aid, err := uuid.Parse(raw)
if err != nil {
Error(w, http.StatusBadRequest, "invalid assignee_id")
return
}
f.AssigneeID = &aid
}
}
items, total, err := s.Support.ListAdmin(r.Context(), f, limit, offset)
if err != nil {
if errors.Is(err, support.ErrForbidden) {
Error(w, http.StatusForbidden, "forbidden")
return
}
if support.IsMissingRelation(err) {
JSON(w, http.StatusOK, map[string]any{"tickets": []any{}, "total": 0, "limit": limit, "offset": offset})
return
}
ClientOrLog(w, http.StatusBadRequest, "could not list tickets", err, support.ClientError)
return
}
if items == nil {
items = []support.Ticket{}
}
JSON(w, http.StatusOK, map[string]any{"tickets": items, "total": total, "limit": limit, "offset": offset, "scope": f.Scope})
}
func (s *Server) handleAdminGetSupportTicket(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Support.GetAdmin(r.Context(), id)
if errors.Is(err, support.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if err != nil {
LogAndError(w, http.StatusInternalServerError, "get failed", err)
return
}
if !s.staffMayAccessTicket(r, uid, item) {
// Anti-enumeration: same as missing for support_staff.
Error(w, http.StatusNotFound, "not found")
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleAdminReplySupportTicket(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := s.Support.GetAdmin(r.Context(), id)
if errors.Is(err, support.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if err != nil {
LogAndError(w, http.StatusInternalServerError, "get failed", err)
return
}
if !s.staffMayAccessTicket(r, uid, existing) {
access, _ := StaffAccessFromContext(r.Context())
if access.IsSupportOnly && existing.AssigneeAdminUserID != nil && *existing.AssigneeAdminUserID != uid {
CodedError(w, http.StatusConflict, "already_claimed", "assigned to another agent")
return
}
Error(w, http.StatusNotFound, "not found")
return
}
var body support.ReplyInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Support.ReplyAsAgent(r.Context(), uid, id, body)
if errors.Is(err, support.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not reply", err, support.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleAdminUpdateSupportTicket(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := s.Support.GetAdmin(r.Context(), id)
if errors.Is(err, support.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if err != nil {
LogAndError(w, http.StatusInternalServerError, "get failed", err)
return
}
if !s.staffMayAccessTicket(r, uid, existing) {
Error(w, http.StatusNotFound, "not found")
return
}
var body support.AdminUpdateInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
// Mass-assignment: only allowlisted fields; validate assignee is support-capable.
if body.AssigneeAdminUserID != nil && !body.ClearAssignee {
if *body.AssigneeAdminUserID == uuid.Nil {
Error(w, http.StatusBadRequest, "invalid assignee")
return
}
access, _ := StaffAccessFromContext(r.Context())
if access.IsSupportOnly && *body.AssigneeAdminUserID != uid {
// support_staff may only claim for self (or clear).
Error(w, http.StatusForbidden, "cannot assign to other staff")
return
}
ok, err := s.assigneeIsSupportCapable(r, *body.AssigneeAdminUserID)
if err != nil {
LogAndError(w, http.StatusInternalServerError, "authorization check failed", err)
return
}
if !ok {
Error(w, http.StatusBadRequest, "invalid assignee")
return
}
}
item, err := s.Support.UpdateAdmin(r.Context(), id, uid, body)
if errors.Is(err, support.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update ticket", err, support.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) staffActor(r *http.Request, uid uuid.UUID) support.AgentActor {
access, _ := StaffAccessFromContext(r.Context())
return support.AgentActor{UserID: uid, FullAdmin: access.FullAdmin}
}
func (s *Server) handleAdminClaimSupportTicket(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Support.Claim(r.Context(), id, s.staffActor(r, uid))
if errors.Is(err, support.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if errors.Is(err, support.ErrAlreadyClaimed) || errors.Is(err, support.ErrNotClaimable) {
Error(w, http.StatusConflict, err.Error())
return
}
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not claim ticket", err, support.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleAdminReleaseSupportTicket(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Support.Release(r.Context(), id, s.staffActor(r, uid))
if errors.Is(err, support.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if errors.Is(err, support.ErrForbidden) {
Error(w, http.StatusForbidden, "forbidden")
return
}
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not release ticket", err, support.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleAdminApproveSupportAIDraft(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := s.Support.GetAdmin(r.Context(), id)
if errors.Is(err, support.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if err != nil {
LogAndError(w, http.StatusInternalServerError, "get failed", err)
return
}
if !s.staffMayAccessTicket(r, uid, existing) {
access, _ := StaffAccessFromContext(r.Context())
if access.IsSupportOnly && existing.AssigneeAdminUserID != nil && *existing.AssigneeAdminUserID != uid {
CodedError(w, http.StatusConflict, "already_claimed", "assigned to another agent")
return
}
Error(w, http.StatusNotFound, "not found")
return
}
var body support.ApproveAIDraftInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Support.ApproveAIDraft(r.Context(), uid, id, body)
if errors.Is(err, support.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if errors.Is(err, support.ErrNoAIDraft) {
Error(w, http.StatusConflict, "no AI draft to approve")
return
}
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not approve AI draft", err, support.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleAdminDiscardSupportAIDraft(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := s.Support.GetAdmin(r.Context(), id)
if errors.Is(err, support.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if err != nil {
LogAndError(w, http.StatusInternalServerError, "get failed", err)
return
}
if !s.staffMayAccessTicket(r, uid, existing) {
access, _ := StaffAccessFromContext(r.Context())
if access.IsSupportOnly && existing.AssigneeAdminUserID != nil && *existing.AssigneeAdminUserID != uid {
CodedError(w, http.StatusConflict, "already_claimed", "assigned to another agent")
return
}
Error(w, http.StatusNotFound, "not found")
return
}
item, err := s.Support.DiscardAIDraft(r.Context(), uid, id)
if errors.Is(err, support.ErrNotFound) {
Error(w, http.StatusNotFound, "not found")
return
}
if errors.Is(err, support.ErrNoAIDraft) {
Error(w, http.StatusConflict, "no AI draft to discard")
return
}
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not discard AI draft", err, support.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleAdminListSupportAgents(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
JSON(w, http.StatusOK, map[string]any{"agents": []any{}, "total": 0})
return
}
limit, offset := ParseLimitOffset(r)
includeAdmins := !QueryTruthy(r, "agents_only")
items, total, err := s.Support.ListAgents(r.Context(), includeAdmins, limit, offset)
if err != nil {
LogAndError(w, http.StatusInternalServerError, "list failed", err)
return
}
if items == nil {
items = []support.SupportAgent{}
}
JSON(w, http.StatusOK, map[string]any{"agents": items, "total": total, "limit": limit, "offset": offset})
}
// staffMayAccessTicket enforces least-privilege visibility for support_staff.
func (s *Server) staffMayAccessTicket(r *http.Request, actor uuid.UUID, t support.Ticket) bool {
access, ok := StaffAccessFromContext(r.Context())
if !ok {
return false
}
if access.FullAdmin {
return true
}
if !access.SupportDesk {
return false
}
if t.AssigneeAdminUserID != nil {
return *t.AssigneeAdminUserID == actor
}
// Unassigned queue: claimable open/pending only.
return t.Status == "open" || t.Status == "pending"
}
func (s *Server) assigneeIsSupportCapable(r *http.Request, assignee uuid.UUID) (bool, error) {
if s.testStaffAccess != nil {
access, err := s.testStaffAccess(r.Context(), assignee)
if err != nil {
return false, err
}
return access.SupportDesk, nil
}
if s.Auth == nil {
return false, nil
}
return s.Auth.IsAssignableSupportStaff(r.Context(), assignee)
}
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
JSON(w, http.StatusOK, map[string]any{"notifications": []any{}, "total": 0, "unread": 0, "limit": 0, "offset": 0})
return
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
limit, offset := ParseLimitOffset(r)
unreadOnly := QueryTruthy(r, "unread")
items, total, err := s.Support.ListNotifications(r.Context(), uid, unreadOnly, limit, offset)
if err != nil {
if support.IsMissingRelation(err) {
JSON(w, http.StatusOK, map[string]any{"notifications": []any{}, "total": 0, "unread": 0, "limit": limit, "offset": offset})
return
}
LogAndError(w, http.StatusInternalServerError, "list failed", err)
return
}
unread, err := s.Support.UnreadNotificationCount(r.Context(), uid)
if err != nil {
if support.IsMissingRelation(err) {
unread = 0
} else {
LogAndError(w, http.StatusInternalServerError, "list failed", err)
return
}
}
if items == nil {
items = []support.Notification{}
}
JSON(w, http.StatusOK, map[string]any{
"notifications": items,
"total": total,
"unread": unread,
"limit": limit,
"offset": offset,
})
}
func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.Support.MarkNotificationRead(r.Context(), uid, id); errors.Is(err, support.ErrNotificationGone) {
Error(w, http.StatusNotFound, "not found")
return
} else if err != nil {
LogAndError(w, http.StatusInternalServerError, "update failed", err)
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
n, err := s.Support.MarkAllNotificationsRead(r.Context(), uid)
if err != nil {
if support.IsMissingRelation(err) {
JSON(w, http.StatusOK, map[string]any{"status": "ok", "updated": 0})
return
}
LogAndError(w, http.StatusInternalServerError, "update failed", err)
return
}
JSON(w, http.StatusOK, map[string]any{"status": "ok", "updated": n})
}
@@ -0,0 +1,348 @@
package httpapi
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/support"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func (s *Server) handleAdminListKBArticles(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
publishedOnly := r.URL.Query().Get("published") == "1" || r.URL.Query().Get("published") == "true"
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
items, total, err := s.Support.ListKBArticlesOpts(r.Context(), support.KBArticleListOpts{
PublishedOnly: publishedOnly,
Category: r.URL.Query().Get("category"),
Query: r.URL.Query().Get("q"),
Limit: limit,
Offset: offset,
})
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not list kb articles", err, support.ClientError)
return
}
JSON(w, http.StatusOK, map[string]any{"items": items, "total": total})
}
func (s *Server) handleAdminListKBCategories(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
items, err := s.Support.ListKBCategories(r.Context())
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not list kb categories", err, support.ClientError)
return
}
JSON(w, http.StatusOK, map[string]any{"items": items})
}
const kbImageMaxUpload = 3 << 20 // parse budget slightly above 2 MiB file cap
func (s *Server) handleAdminUploadKBImage(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
if err := r.ParseMultipartForm(kbImageMaxUpload); err != nil {
Error(w, http.StatusBadRequest, "invalid multipart form")
return
}
file, header, err := r.FormFile("file")
if err != nil {
file, header, err = r.FormFile("image")
}
if err != nil {
Error(w, http.StatusBadRequest, "file field required")
return
}
defer file.Close()
out, err := support.SaveKBImage(
s.Config.UploadDir,
s.Config.PublicAPIURL,
s.Config.TokenSigningSecret,
header.Filename,
header.Header.Get("Content-Type"),
file,
)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not upload kb image", err, support.ClientError)
return
}
JSON(w, http.StatusCreated, out)
}
func (s *Server) handleAdminGetKBImage(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "filename")
s.serveKBImage(w, r, name)
}
func (s *Server) handlePublicKBImage(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "filename")
sig := strings.TrimSpace(r.URL.Query().Get("sig"))
secret := strings.TrimSpace(s.Config.TokenSigningSecret)
if err := support.VerifyKBImageSig(secret, name, sig); err != nil {
Error(w, http.StatusForbidden, "invalid image signature")
return
}
s.serveKBImage(w, r, name)
}
func (s *Server) serveKBImage(w http.ResponseWriter, r *http.Request, name string) {
f, contentType, err := support.OpenKBImage(s.Config.UploadDir, name)
if err != nil {
switch {
case errors.Is(err, support.ErrKBImageInvalidName), errors.Is(err, support.ErrKBImageForbidden):
Error(w, http.StatusBadRequest, "invalid image path")
case errors.Is(err, support.ErrKBImageNotFound):
Error(w, http.StatusNotFound, "image not found")
default:
Error(w, http.StatusInternalServerError, "could not open image")
}
return
}
defer f.Close()
st, err := f.Stat()
if err != nil {
Error(w, http.StatusInternalServerError, "could not stat image")
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeContent(w, r, name, st.ModTime(), f)
}
func (s *Server) handleAdminGetKBArticle(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Support.GetKBArticle(r.Context(), id)
if err != nil {
if err == support.ErrKBNotFound {
Error(w, http.StatusNotFound, err.Error())
return
}
ClientOrLog(w, http.StatusBadRequest, "could not get kb article", err, support.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleAdminCreateKBArticle(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
var body support.KBArticleInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Support.CreateKBArticle(r.Context(), body)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not create kb article", err, support.ClientError)
return
}
JSON(w, http.StatusCreated, item)
}
func (s *Server) handleAdminUpdateKBArticle(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body support.KBArticleInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Support.UpdateKBArticle(r.Context(), id, body)
if err != nil {
if err == support.ErrKBNotFound {
Error(w, http.StatusNotFound, err.Error())
return
}
ClientOrLog(w, http.StatusBadRequest, "could not update kb article", err, support.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleAdminDeleteKBArticle(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.Support.DeleteKBArticle(r.Context(), id); err != nil {
if err == support.ErrKBNotFound {
Error(w, http.StatusNotFound, err.Error())
return
}
ClientOrLog(w, http.StatusBadRequest, "could not delete kb article", err, support.ClientError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleAdminListReplyTemplates(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
activeOnly := r.URL.Query().Get("active") == "1" || r.URL.Query().Get("active") == "true"
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
items, total, err := s.Support.ListReplyTemplates(r.Context(), activeOnly, limit, offset)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not list templates", err, support.ClientError)
return
}
JSON(w, http.StatusOK, map[string]any{"items": items, "total": total})
}
func (s *Server) handleAdminGetReplyTemplate(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Support.GetReplyTemplate(r.Context(), id)
if err != nil {
if err == support.ErrTemplateNotFound {
Error(w, http.StatusNotFound, err.Error())
return
}
ClientOrLog(w, http.StatusBadRequest, "could not get template", err, support.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleAdminCreateReplyTemplate(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
var body support.ReplyTemplateInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Support.CreateReplyTemplate(r.Context(), body)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not create template", err, support.ClientError)
return
}
JSON(w, http.StatusCreated, item)
}
func (s *Server) handleAdminUpdateReplyTemplate(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
var body support.ReplyTemplateInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
item, err := s.Support.UpdateReplyTemplate(r.Context(), id, body)
if err != nil {
if err == support.ErrTemplateNotFound {
Error(w, http.StatusNotFound, err.Error())
return
}
ClientOrLog(w, http.StatusBadRequest, "could not update template", err, support.ClientError)
return
}
JSON(w, http.StatusOK, item)
}
func (s *Server) handleAdminDeleteReplyTemplate(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.Support.DeleteReplyTemplate(r.Context(), id); err != nil {
if err == support.ErrTemplateNotFound {
Error(w, http.StatusNotFound, err.Error())
return
}
ClientOrLog(w, http.StatusBadRequest, "could not delete template", err, support.ClientError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleAdminGetSupportAutoConfig(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
cfg, err := s.Support.GetAutoConfig(r.Context())
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not load auto config", err, support.ClientError)
return
}
JSON(w, http.StatusOK, cfg)
}
func (s *Server) handleAdminPutSupportAutoConfig(w http.ResponseWriter, r *http.Request) {
if s.Support == nil {
Error(w, http.StatusServiceUnavailable, "support unavailable")
return
}
var body support.AutoConfigInput
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
cfg, err := s.Support.UpdateAutoConfig(r.Context(), body)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not update auto config", err, support.ClientError)
return
}
JSON(w, http.StatusOK, cfg)
}
+308
View File
@@ -0,0 +1,308 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/google/uuid"
)
func TestContextTenantKeysDoNotCross(t *testing.T) {
t.Parallel()
companyA := uuid.MustParse("11111111-1111-1111-1111-111111111111")
companyB := uuid.MustParse("22222222-2222-2222-2222-222222222222")
userA := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
userB := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
ctxA := context.WithValue(context.Background(), ctxUserID, userA)
ctxA = context.WithValue(ctxA, ctxCompanyID, companyA)
ctxA = context.WithValue(ctxA, ctxRole, "admin")
ctxB := context.WithValue(context.Background(), ctxUserID, userB)
ctxB = context.WithValue(ctxB, ctxCompanyID, companyB)
ctxB = context.WithValue(ctxB, ctxRole, "member")
gotUserA, ok := UserIDFromContext(ctxA)
if !ok || gotUserA != userA {
t.Fatalf("user A = %v ok=%v", gotUserA, ok)
}
gotCompanyA, ok := CompanyIDFromContext(ctxA)
if !ok || gotCompanyA != companyA {
t.Fatalf("company A = %v ok=%v", gotCompanyA, ok)
}
gotCompanyB, ok := CompanyIDFromContext(ctxB)
if !ok || gotCompanyB != companyB {
t.Fatalf("company B = %v ok=%v", gotCompanyB, ok)
}
if gotCompanyA == gotCompanyB {
t.Fatal("tenant company IDs unexpectedly equal")
}
roleA, _ := RoleFromContext(ctxA)
roleB, _ := RoleFromContext(ctxB)
if roleA == roleB {
t.Fatal("roles should differ across tenants")
}
}
func TestRequireSessionUnauthorized(t *testing.T) {
t.Parallel()
sm := scs.New()
s := &Server{Sessions: sm, Config: config.Config{}, Auth: &auth.Service{}}
h := LoadSession(sm)(s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
func TestRequireSessionRejectsInactiveUser(t *testing.T) {
t.Parallel()
sm := scs.New()
uid := uuid.New()
var capturedToken string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
capturedToken = c.Value
}
}
if capturedToken == "" {
t.Fatal("expected session cookie from seed request")
}
s := &Server{
Sessions: sm,
Config: config.Config{},
testUserActive: func(_ context.Context, got uuid.UUID) (bool, error) {
if got != uid {
t.Fatalf("user id = %s, want %s", got, uid)
}
return false, nil
},
}
h := LoadSession(sm)(s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: capturedToken})
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 for inactive user", rec.Code)
}
}
func TestRequireSessionRejectsStaleSessionVersion(t *testing.T) {
t.Parallel()
sm := scs.New()
uid := uuid.New()
var capturedToken string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
sm.Put(r.Context(), auth.SessionVersionKey, 0)
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
capturedToken = c.Value
}
}
if capturedToken == "" {
t.Fatal("expected session cookie from seed request")
}
s := &Server{
Sessions: sm,
Config: config.Config{},
testUserSessionState: func(_ context.Context, got uuid.UUID) (auth.UserSessionState, error) {
if got != uid {
t.Fatalf("user id = %s, want %s", got, uid)
}
// Simulate password-reset bump while cookie still carries version 0.
return auth.UserSessionState{Active: true, Version: 1}, nil
},
}
h := LoadSession(sm)(s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: capturedToken})
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 for stale session_version", rec.Code)
}
}
func TestRequireCompanyRequiresSelection(t *testing.T) {
t.Parallel()
sm := scs.New()
s := &Server{Sessions: sm, Config: config.Config{}, Auth: &auth.Service{}}
uid := uuid.New()
var capturedToken string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
capturedToken = c.Value
}
}
if capturedToken == "" {
t.Fatal("expected session cookie from seed request")
}
h := LoadSession(sm)(s.RequireSession(s.RequireCompany(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/company", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: capturedToken})
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 company not selected", rec.Code)
}
}
func TestRequireCompanyRejectsUnprovenMembership(t *testing.T) {
t.Parallel()
// Without a DB pool, membership cannot be proven — gate must not panic and should reject.
// Live round-trip requires DATABASE_URL (documented blocker for integration tests).
sm := scs.New()
s := &Server{Sessions: sm, Config: config.Config{}, Auth: &auth.Service{Pool: nil}}
uid := uuid.New()
cid := uuid.New()
var capturedToken string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
sm.Put(r.Context(), auth.SessionCompanyIDKey, cid.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
capturedToken = c.Value
}
}
if capturedToken == "" {
t.Fatal("expected session cookie from seed request")
}
h := LoadSession(sm)(s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate RequireCompany's invalid-company path without hitting nil pool.
cidStr := s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey)
if cidStr == "" {
Error(w, http.StatusBadRequest, "company not selected")
return
}
parsed, err := uuid.Parse(cidStr)
if err != nil || parsed == uuid.Nil {
Error(w, http.StatusBadRequest, "invalid company")
return
}
// Tenant isolation: company from session must match what handlers would use.
if parsed != cid {
Error(w, http.StatusForbidden, "forbidden")
return
}
Error(w, http.StatusForbidden, "forbidden")
})))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/company", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: capturedToken})
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 when membership cannot be proven", rec.Code)
}
}
func TestBeginAuthenticatedSessionRenewsTokenAndClearsCompany(t *testing.T) {
t.Parallel()
sm := scs.New()
sm.Cookie.Name = "descrybe_session"
s := &Server{Sessions: sm, Config: config.Config{}, Auth: &auth.Service{}}
userID := uuid.New()
staleCompanyID := uuid.New()
var originalToken string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionCompanyIDKey, staleCompanyID.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
originalToken = c.Value
}
}
if originalToken == "" {
t.Fatal("expected seeded session cookie")
}
var renewedToken string
authenticate := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := s.beginAuthenticatedSession(r.Context(), userID, uuid.Nil); err != nil {
t.Fatalf("beginAuthenticatedSession error: %v", err)
}
w.WriteHeader(http.StatusNoContent)
}))
authReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
authReq.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: originalToken})
authRec := httptest.NewRecorder()
authenticate.ServeHTTP(authRec, authReq)
for _, c := range authRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
renewedToken = c.Value
}
}
if renewedToken == "" {
t.Fatal("expected renewed session cookie")
}
if renewedToken == originalToken {
t.Fatal("expected session token rotation after authentication")
}
verify := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := s.Sessions.GetString(r.Context(), auth.SessionUserIDKey); got != userID.String() {
t.Fatalf("user session = %q, want %q", got, userID.String())
}
if got := s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey); got != "" {
t.Fatalf("company session = %q, want cleared value", got)
}
if got := s.Sessions.GetInt(r.Context(), auth.SessionVersionKey); got != 0 {
t.Fatalf("session_version = %d, want 0 without DB", got)
}
w.WriteHeader(http.StatusNoContent)
}))
verifyReq := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
verifyReq.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: renewedToken})
verifyRec := httptest.NewRecorder()
verify.ServeHTTP(verifyRec, verifyReq)
if verifyRec.Code != http.StatusNoContent {
t.Fatalf("verify status = %d, want 204", verifyRec.Code)
}
}
+157
View File
@@ -0,0 +1,157 @@
package httpapi
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// Stable ETag for the embedded public OpenAPI document (compile-time bytes).
var v1OpenAPIETag = func() string {
sum := sha256.Sum256(v1OpenAPIYAML)
return `"` + hex.EncodeToString(sum[:16]) + `"`
}()
// Precompressed OpenAPI body (~12KB vs ~74KB raw) for Accept-Encoding: gzip.
var v1OpenAPIGzip = func() []byte {
var buf bytes.Buffer
zw, err := gzip.NewWriterLevel(&buf, gzip.BestCompression)
if err != nil {
return nil
}
if _, err := zw.Write(v1OpenAPIYAML); err != nil {
_ = zw.Close()
return nil
}
if err := zw.Close(); err != nil {
return nil
}
return buf.Bytes()
}()
func acceptEncodingIncludesGzip(header string) bool {
for _, part := range strings.Split(header, ",") {
encoding := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
if strings.EqualFold(encoding, "gzip") {
return true
}
}
return false
}
// mountV1 registers the public API-key surface under /api/v1.
// Handlers reuse dashboard services with company isolation from RequireAPIKey.
func (s *Server) mountV1(r chi.Router) {
r.Route("/api/v1", func(r chi.Router) {
r.Get("/openapi.yaml", s.handleV1OpenAPI)
r.Get("/health", s.handleHealthz)
r.Group(func(r chi.Router) {
r.Use(s.RateLimitAPIKeyAttempts)
r.Use(s.RequireAPIKey)
r.Use(s.RateLimitAPIKey)
r.Use(s.RateLimitV1Process)
r.Get("/products", s.handleV1ListProducts)
r.Get("/products/quality", s.handleV1ListProductQuality)
r.Post("/products/reset", s.handleResetProducts)
// Legacy public contract (items[].ean → 200 { data: { process_id } }).
// Not an alias of POST/GET /process (flat ProcessingJob).
r.Post("/products/process", s.handleV1StartProcess)
r.Get("/products/process/{id}", s.handleV1GetProcess)
r.Get("/products/{id}", s.handleGetProduct)
r.Patch("/products/{id}", s.handleUpdateProduct)
// Content calendar — separate from email /api/campaigns (session UI).
r.Get("/marketing/calendar", s.handleGetMarketingCalendar)
r.Post("/marketing/calendar/prepare", s.handlePrepareMarketingCalendar)
// Legacy public aliases (Next.js /api/v1/campaigns).
r.Get("/campaigns", s.handleV1ListCampaigns)
r.Post("/campaigns/prepare", s.handleV1PrepareCampaign)
r.Get("/categories", s.handleV1ListCategories)
r.Post("/categories", s.handleV1CreateCategory)
r.Post("/categories/create", s.handleV1CreateCategory) // legacy alias
r.Get("/categories/{id}", s.handleGetCategory)
r.Patch("/categories/{id}", s.handleUpdateCategory)
r.Delete("/categories/{id}", s.handleV1DeleteCategory)
r.Get("/attributes", s.handleV1ListAttributes)
r.Post("/attributes", s.handleV1CreateAttribute)
r.Post("/attributes/create", s.handleV1CreateAttribute) // legacy alias
r.Patch("/attributes/{id}", s.handleUpdateAttribute)
r.Delete("/attributes/{id}", s.handleV1DeleteAttribute)
r.Get("/feeds", s.handleV1ListFeeds)
r.Post("/feeds", s.handleV1CreateFeed)
r.Get("/feeds/{id}", s.handleV1GetFeed)
r.Patch("/feeds/{id}", s.handleUpdateFeed)
r.Delete("/feeds/{id}", s.handleDeleteFeed)
r.Post("/feeds/{id}/sync", s.handleV1SyncFeed)
r.Get("/feeds/{id}/mappings", s.handleGetFeedMappings)
r.Put("/feeds/{id}/mappings", s.handlePutFeedMappings)
r.Post("/feeds/{id}/extract-schema", s.handleExtractFeedSchema)
r.Post("/feeds/{id}/sync-process-sample", s.handleSyncAndProcessSample)
r.Get("/export-feeds", s.handleV1ListExportFeeds)
r.Post("/export-feeds", s.handleV1CreateExportFeed)
r.Get("/export-feeds/{id}", s.handleGetExportFeed)
r.Patch("/export-feeds/{id}", s.handleUpdateExportFeed)
r.Put("/export-feeds/{id}/template", s.handleUpdateExportFeedTemplate)
r.Delete("/export-feeds/{id}", s.handleDeleteExportFeed)
r.Post("/export-feeds/{id}/rotate-token", s.handleRotateExportFeedPublicToken)
r.Post("/export-feeds/{id}/generate", s.handleV1GenerateExportFeed)
r.Post("/export-feeds/{id}/export-products", s.handleExportSelectedProducts)
// Dashboard-style jobs (flat JSON / 202). Prefer /products/process for legacy integrations.
r.Post("/process", s.handleStartProcessingJob)
r.Get("/process", s.handleV1ListProcessJobs)
r.Get("/process/{id}", s.handleGetProcessingJob)
r.Post("/process/{id}/cancel", s.handleCancelProcessingJob)
r.Post("/process/{id}/terminate", s.handleCancelProcessingJob)
r.Post("/process/{id}/retry", s.handleRetryProcessingJob)
})
})
}
func (s *Server) handleV1ListProcessJobs(w http.ResponseWriter, r *http.Request) {
cid, ok := CompanyIDFromContext(r.Context())
if !ok || cid == uuid.Nil {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
limit, _ := ParseLimitOffset(r)
items, err := s.Processing.ListJobs(r.Context(), cid, limit)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{"jobs": processing.FormatListJobsResponse(items), "limit": limit})
}
func (s *Server) handleV1OpenAPI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
// Public, immutable-for-process document: browsers / API clients can reuse across visits.
w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
w.Header().Set("ETag", v1OpenAPIETag)
w.Header().Set("Vary", "Accept-Encoding")
if match := r.Header.Get("If-None-Match"); match != "" && match == v1OpenAPIETag {
w.WriteHeader(http.StatusNotModified)
return
}
if len(v1OpenAPIGzip) > 0 && acceptEncodingIncludesGzip(r.Header.Get("Accept-Encoding")) {
w.Header().Set("Content-Encoding", "gzip")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(v1OpenAPIGzip)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(v1OpenAPIYAML)
}
+198
View File
@@ -0,0 +1,198 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestExtractAPIKeyBearerAndHeader(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
r.Header.Set("Authorization", "Bearer dk_abc")
if got := extractAPIKey(r); got != "dk_abc" {
t.Fatalf("bearer: got %q", got)
}
r2 := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
r2.Header.Set("X-API-Key", "dk_xyz")
if got := extractAPIKey(r2); got != "dk_xyz" {
t.Fatalf("x-api-key: got %q", got)
}
r3 := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
r3.Header.Set("X-API-Key", "dk_header")
r3.Header.Set("Authorization", "Bearer dk_bearer")
if got := extractAPIKey(r3); got != "dk_bearer" {
t.Fatalf("bearer should win (legacy): got %q", got)
}
r3b := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
r3b.Header.Set("X-Api-Key", "dk_legacy_spelling")
if got := extractAPIKey(r3b); got != "dk_legacy_spelling" {
t.Fatalf("X-Api-Key: got %q", got)
}
r4 := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
if got := extractAPIKey(r4); got != "" {
t.Fatalf("missing key: got %q", got)
}
}
func TestParseLimitOffset(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/x?limit=10&offset=5", nil)
limit, offset := ParseLimitOffset(r)
if limit != 10 || offset != 5 {
t.Fatalf("got limit=%d offset=%d", limit, offset)
}
r2 := httptest.NewRequest(http.MethodGet, "/x?limit=999&offset=-1", nil)
limit, offset = ParseLimitOffset(r2)
if limit != maxPageLimit || offset != 0 {
t.Fatalf("caps: got limit=%d offset=%d want max=%d", limit, offset, maxPageLimit)
}
// Invalid / missing params are silently normalized (not 400).
r3 := httptest.NewRequest(http.MethodGet, "/x?limit=abc&offset=xyz", nil)
limit, offset = ParseLimitOffset(r3)
if limit != defaultPageLimit || offset != 0 {
t.Fatalf("invalid normalize: got limit=%d offset=%d", limit, offset)
}
r4 := httptest.NewRequest(http.MethodGet, "/x", nil)
limit, offset = ParseLimitOffset(r4)
if limit != defaultPageLimit || offset != 0 {
t.Fatalf("defaults: got limit=%d offset=%d", limit, offset)
}
}
func TestParseLimitOffsetMax(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/x?limit=1500", nil)
limit, _ := ParseLimitOffsetMax(r, maxTreePageLimit)
if limit != 1500 {
t.Fatalf("got limit=%d want 1500", limit)
}
limit, _ = ParseLimitOffsetMax(r, maxPageLimit)
if limit != maxPageLimit {
t.Fatalf("got limit=%d want %d", limit, maxPageLimit)
}
}
func TestPageSlice(t *testing.T) {
items := []int{1, 2, 3, 4, 5}
page, total := pageSlice(items, 2, 1)
if total != 5 || len(page) != 2 || page[0] != 2 || page[1] != 3 {
t.Fatalf("page=%v total=%d", page, total)
}
page, total = pageSlice(items, 10, 10)
if total != 5 || len(page) != 0 {
t.Fatalf("empty page expected, got %v total=%d", page, total)
}
}
func TestListCampaignsNilServicePreservesParsedLimit(t *testing.T) {
s := &Server{}
r := httptest.NewRequest(http.MethodGet, "/api/campaigns?limit=7&offset=3", nil)
w := httptest.NewRecorder()
s.handleListCampaigns(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `"limit":7`) || !strings.Contains(body, `"offset":3`) || !strings.Contains(body, `"total":0`) {
t.Fatalf("unexpected body %s", body)
}
}
func TestV1OpenAPIDocumentsPublicAPIAuth(t *testing.T) {
body := string(v1OpenAPIYAML)
for _, want := range []string{
"BearerAuth:",
"ApiKeyAuth:",
"name: X-API-Key",
"## Authentication",
"/settings?tab=api-keys",
"https://descrybe.io/api/v1",
"Use my API key",
"30 requests per minute",
"Retry-After",
"rate limit exceeded",
"code: unauthorized",
"LegacyAPIError",
"security: []",
} {
if !strings.Contains(body, want) {
t.Fatalf("OpenAPI missing auth doc %q", want)
}
}
if !strings.Contains(body, "Forbidden:") {
t.Fatal("OpenAPI missing Forbidden response component")
}
// Public probes must opt out of document-level API-key security.
if !strings.Contains(body, "/health:") || !strings.Contains(body, "/openapi.yaml:") {
t.Fatal("OpenAPI missing public health/openapi paths")
}
for _, heavy := range []string{
"/products/process:",
"/feeds/{id}/sync:",
"/feeds/{id}/extract-schema:",
"/feeds/{id}/sync-process-sample:",
"/export-feeds/{id}/generate:",
"/export-feeds/{id}/export-products:",
"/process:",
"/process/{id}/retry:",
} {
if !strings.Contains(body, heavy) {
t.Fatalf("OpenAPI missing heavy path %q", heavy)
}
}
// Heavy mutations document 429 via shared component.
if strings.Count(body, `"429": { $ref: "#/components/responses/TooManyRequests" }`) < 6 {
t.Fatalf("expected multiple TooManyRequests refs on heavy mutations, got %d",
strings.Count(body, `"429": { $ref: "#/components/responses/TooManyRequests" }`))
}
}
func TestV1OpenAPIRouteMounted(t *testing.T) {
s := &Server{}
r := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil)
w := httptest.NewRecorder()
s.handleV1OpenAPI(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d", w.Code)
}
if body := w.Body.String(); len(body) < 20 || body[:8] != "openapi:" {
t.Fatalf("unexpected body prefix %q", body[:min(20, len(body))])
}
if cc := w.Header().Get("Cache-Control"); !strings.Contains(cc, "max-age=") || !strings.Contains(cc, "stale-while-revalidate=") {
t.Fatalf("unexpected Cache-Control %q", cc)
}
if vary := w.Header().Get("Vary"); !strings.Contains(vary, "Accept-Encoding") {
t.Fatalf("unexpected Vary %q", vary)
}
etag := w.Header().Get("ETag")
if etag == "" {
t.Fatal("missing ETag")
}
r304 := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil)
r304.Header.Set("If-None-Match", etag)
w304 := httptest.NewRecorder()
s.handleV1OpenAPI(w304, r304)
if w304.Code != http.StatusNotModified {
t.Fatalf("If-None-Match status %d", w304.Code)
}
rGzip := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil)
rGzip.Header.Set("Accept-Encoding", "gzip")
wGzip := httptest.NewRecorder()
s.handleV1OpenAPI(wGzip, rGzip)
if wGzip.Code != http.StatusOK {
t.Fatalf("gzip status %d", wGzip.Code)
}
if wGzip.Header().Get("Content-Encoding") != "gzip" {
t.Fatalf("expected Content-Encoding gzip, got %q", wGzip.Header().Get("Content-Encoding"))
}
if len(wGzip.Body.Bytes()) == 0 || wGzip.Body.Len() >= len(v1OpenAPIYAML) {
t.Fatalf("gzip body should be non-empty and smaller than raw (%d vs %d)", wGzip.Body.Len(), len(v1OpenAPIYAML))
}
}
@@ -0,0 +1,199 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/google/uuid"
)
func testAPIServer() *Server {
sm := scs.New()
sm.Cookie.Name = "descrybe_session"
return &Server{
Config: config.Config{
CSRFCookieName: "descrybe_csrf",
WebOrigin: "http://localhost:5173",
},
Sessions: sm,
Auth: &auth.Service{},
}
}
func TestRequireAPIKeyUnauthorizedWithoutKey(t *testing.T) {
t.Parallel()
s := testAPIServer()
h := s.RequireAPIKey(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/products", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `"code":"unauthorized"`) || !strings.Contains(body, `"message":"Unauthorized"`) {
t.Fatalf("want legacy coded error envelope, got %s", body)
}
}
func TestRequireAPIKeyBindsTenantContext(t *testing.T) {
t.Parallel()
companyID := uuid.MustParse("11111111-1111-1111-1111-111111111111")
userID := uuid.MustParse("22222222-2222-2222-2222-222222222222")
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), ctxUserID, userID)
ctx = context.WithValue(ctx, ctxCompanyID, companyID)
ctx = context.WithValue(ctx, ctxRole, "api")
cid, ok := CompanyIDFromContext(ctx)
if !ok || cid != companyID {
t.Fatalf("company binding failed: %v ok=%v", cid, ok)
}
uid, ok := UserIDFromContext(ctx)
if !ok || uid != userID {
t.Fatalf("user binding failed: %v ok=%v", uid, ok)
}
role, _ := RoleFromContext(ctx)
if role != "api" {
t.Fatalf("role = %q", role)
}
w.WriteHeader(http.StatusNoContent)
})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/products", nil))
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d", rec.Code)
}
}
func TestRouterV1POSTSkipsCSRFDashboardStillRequires(t *testing.T) {
t.Parallel()
s := testAPIServer()
h := s.Router()
// /api/v1 mutating call without CSRF cookie/header must not be 403 csrf;
// without a valid API key it should be 401 from RequireAPIKey.
v1 := httptest.NewRecorder()
reqV1 := httptest.NewRequest(http.MethodPost, "/api/v1/categories", nil)
h.ServeHTTP(v1, reqV1)
if v1.Code == http.StatusForbidden {
t.Fatalf("v1 must skip CSRF; got 403 body=%s", v1.Body.String())
}
if v1.Code != http.StatusUnauthorized {
t.Fatalf("v1 without API key status = %d, want 401", v1.Code)
}
dash := httptest.NewRecorder()
reqDash := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
h.ServeHTTP(dash, reqDash)
if dash.Code != http.StatusForbidden {
t.Fatalf("dashboard POST without CSRF status = %d, want 403", dash.Code)
}
}
func TestRouterV1OpenAPIAndHealthNoAPIKey(t *testing.T) {
t.Parallel()
s := testAPIServer()
h := s.Router()
openAPI := httptest.NewRecorder()
h.ServeHTTP(openAPI, httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil))
if openAPI.Code != http.StatusOK {
t.Fatalf("openapi status = %d", openAPI.Code)
}
health := httptest.NewRecorder()
h.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/api/v1/health", nil))
if health.Code != http.StatusOK {
t.Fatalf("v1 health status = %d", health.Code)
}
}
func TestRouterV1LegacyAliasesRequireAPIKey(t *testing.T) {
t.Parallel()
s := testAPIServer()
h := s.Router()
paths := []struct {
method string
path string
}{
{http.MethodPost, "/api/v1/products/process"},
{http.MethodGet, "/api/v1/products/process/11111111-1111-1111-1111-111111111111"},
{http.MethodPost, "/api/v1/categories/create"},
{http.MethodPost, "/api/v1/attributes/create"},
{http.MethodPost, "/api/v1/process"},
}
for _, tc := range paths {
rec := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s %s status = %d, want 401", tc.method, tc.path, rec.Code)
}
}
}
func TestV1OpenAPIIncludesProcessAndFeeds(t *testing.T) {
t.Parallel()
body := string(v1OpenAPIYAML)
for _, needle := range []string{
"/products/process:",
"/process:",
"/feeds:",
"/categories/create:",
"/attributes/create:",
"raw_product_ids",
"items[].ean",
"process_id",
"LegacyStartProcessByEAN",
"StartProcessByRawIDs",
"LegacyProcessCompleted",
"X-API-Key",
"https://descrybe.io/api/v1",
"BearerAuth",
"ApiKeyAuth",
"Use my API key",
"Settings -> API keys",
"mapped_total",
"active_total",
"needs_review",
"HealthStatus",
"maintenance",
"read_only",
"FeedListResponse",
"ProductListResponse",
"PresentProduct",
"ProductQualityListResponse",
"/products/quality:",
"Wireless earbuds",
"ProcessingJobAccepted",
// Team/Admin paths are dashboard OFF_SURFACE (session+CSRF under /api),
// not part of the public API-key contract needles for this doc.
"ReissueSetPasswordInvite",
"cannot demote the last admin",
"skipped_synthetic",
"SessionCookie",
"CSRFHeader",
"code: unauthorized",
"message: Unauthorized",
"legacy envelope",
"/process/{id}/retry:",
} {
if !strings.Contains(body, needle) {
t.Fatalf("openapi missing %q", needle)
}
}
// Dual-mode: legacy EAN path must not be described as an alias of /process.
if strings.Contains(body, "Alias of POST /process") {
t.Fatal("openapi still treats /products/process as alias of /process")
}
}
@@ -0,0 +1,496 @@
package httpapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// TestV1DomainResourceCRUD exercises public /api/v1 catalog+feed CRUD with
// semi-real merchant data against a live DATABASE_URL (skips when unset).
func TestV1DomainResourceCRUD(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("postgres: %v", err)
}
defer pg.Close()
companyID := uuid.New()
userID := uuid.New()
prefix := companyID.String()[:8]
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`,
companyID, "merchant-crud-"+prefix)
if err != nil {
t.Fatalf("seed company: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, companyID)
})
s := &Server{
Config: config.Config{WebOrigin: "http://localhost:5173"},
Pool: pg,
Catalog: &catalog.Service{Pool: pg},
Feeds: &feeds.Service{Pool: pg},
}
h := mountV1DomainTestRouter(s)
withTenant := func(r *http.Request) *http.Request {
c := context.WithValue(r.Context(), ctxCompanyID, companyID)
c = context.WithValue(c, ctxUserID, userID)
c = context.WithValue(c, ctxRole, "api")
return r.WithContext(c)
}
do := func(method, path, body string) *httptest.ResponseRecorder {
var req *http.Request
if body == "" {
req = httptest.NewRequest(method, path, nil)
} else {
req = httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, withTenant(req))
return rec
}
decode := func(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("json status=%d body=%s err=%v", rec.Code, rec.Body.String(), err)
}
return out
}
// --- Categories CRUD ---
catUnique := "electronics-" + prefix
rec := do(http.MethodPost, "/api/v1/categories", fmt.Sprintf(
`{"name":"Electronics","unique_id":%q,"description":"Consumer electronics for Nordic merchants"}`, catUnique))
if rec.Code != http.StatusCreated {
t.Fatalf("create category status=%d body=%s", rec.Code, rec.Body.String())
}
createdCat := decode(t, rec)
catData, _ := createdCat["data"].(map[string]any)
if catData["unique_id"] != catUnique || catData["name"] != "Electronics" {
t.Fatalf("create category data=%v", catData)
}
catUUID, err := uuid.Parse(fmt.Sprint(catData["id"]))
if err != nil {
t.Fatalf("category id: %v", err)
}
childUnique := "headphones-" + prefix
rec = do(http.MethodPost, "/api/v1/categories/create", fmt.Sprintf(
`{"name":"Headphones","unique_id":%q,"parent_id":%q}`, childUnique, catUnique))
if rec.Code != http.StatusCreated {
t.Fatalf("create child category status=%d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodGet, "/api/v1/categories?page=1&limit=25&search=Electronics", "")
if rec.Code != http.StatusOK {
t.Fatalf("list categories status=%d body=%s", rec.Code, rec.Body.String())
}
listCat := decode(t, rec)
if _, ok := listCat["data"]; !ok {
t.Fatalf("list categories missing data envelope: %v", listCat)
}
meta, _ := listCat["meta"].(map[string]any)
if meta["page"].(float64) != 1 || meta["limit"].(float64) != 25 {
t.Fatalf("list categories meta=%v", meta)
}
rec = do(http.MethodGet, "/api/v1/categories/"+catUUID.String(), "")
if rec.Code != http.StatusOK {
t.Fatalf("get category status=%d body=%s", rec.Code, rec.Body.String())
}
gotCat := decode(t, rec)
if _, hasData := gotCat["data"]; hasData {
t.Fatalf("GET /categories/{uuid} should be flat dashboard JSON, got envelope: %v", gotCat)
}
if gotCat["unique_id"] != catUnique {
t.Fatalf("get category=%v", gotCat)
}
rec = do(http.MethodPatch, "/api/v1/categories/"+catUUID.String(),
`{"name":"Electronics & Audio"}`)
if rec.Code != http.StatusOK {
t.Fatalf("patch category status=%d body=%s", rec.Code, rec.Body.String())
}
patchedCat := decode(t, rec)
if patchedCat["name"] != "Electronics & Audio" {
t.Fatalf("patched category=%v", patchedCat)
}
// --- Attributes CRUD ---
rec = do(http.MethodPost, "/api/v1/attributes", fmt.Sprintf(
`{"name":"Color","attribute_key":"color_%s","value_type":"string","category_unique_id":%q,"required":true}`,
prefix, catUnique))
if rec.Code != http.StatusCreated {
t.Fatalf("create attribute status=%d body=%s", rec.Code, rec.Body.String())
}
attrEnv := decode(t, rec)
attrData, _ := attrEnv["data"].(map[string]any)
if attrData["key"] == nil || attrData["category_unique_id"] != catUnique || attrData["required"] != true {
t.Fatalf("create attribute data=%v", attrData)
}
attrID := fmt.Sprint(attrData["id"])
rec = do(http.MethodGet, "/api/v1/attributes?page=1&limit=25&categoryId="+catUnique, "")
if rec.Code != http.StatusOK {
t.Fatalf("list attributes status=%d body=%s", rec.Code, rec.Body.String())
}
listAttr := decode(t, rec)
if _, ok := listAttr["data"]; !ok {
t.Fatalf("list attributes missing data: %v", listAttr)
}
rec = do(http.MethodPatch, "/api/v1/attributes/"+attrID, `{"name":"Colour"}`)
if rec.Code != http.StatusOK {
t.Fatalf("patch attribute status=%d body=%s", rec.Code, rec.Body.String())
}
// --- Feeds CRUD ---
// Legacy create: name + item_path without URL (avoids live SSRF DNS for merchant hosts).
rec = do(http.MethodPost, "/api/v1/feeds", `{
"name":"Main catalog XML",
"item_path":"channel/item",
"feed_type":"xml",
"sync_interval_minutes":60,
"is_active":true
}`)
if rec.Code != http.StatusCreated {
t.Fatalf("create feed status=%d body=%s", rec.Code, rec.Body.String())
}
feedEnv := decode(t, rec)
feedData, _ := feedEnv["data"].(map[string]any)
if feedData["name"] != "Main catalog XML" || feedData["item_path"] != "channel/item" {
t.Fatalf("create feed data=%v", feedData)
}
if feedData["is_active"] != true {
t.Fatalf("create feed is_active should be true after flip, got %v", feedData)
}
feedID := fmt.Sprint(feedData["id"])
rec = do(http.MethodGet, "/api/v1/feeds?page=1&limit=25", "")
if rec.Code != http.StatusOK {
t.Fatalf("list feeds status=%d body=%s", rec.Code, rec.Body.String())
}
listFeeds := decode(t, rec)
if _, ok := listFeeds["data"]; !ok {
t.Fatalf("list feeds missing data: %v", listFeeds)
}
rec = do(http.MethodGet, "/api/v1/feeds/"+feedID, "")
if rec.Code != http.StatusOK {
t.Fatalf("get feed status=%d body=%s", rec.Code, rec.Body.String())
}
getFeed := decode(t, rec)
getFeedData, _ := getFeed["data"].(map[string]any)
if getFeedData["id"] != feedID {
t.Fatalf("get feed=%v", getFeed)
}
rec = do(http.MethodPatch, "/api/v1/feeds/"+feedID, `{"name":"Main catalog XML (Nordic)"}`)
if rec.Code != http.StatusOK {
t.Fatalf("patch feed status=%d body=%s", rec.Code, rec.Body.String())
}
patchFeed := decode(t, rec)
if _, hasData := patchFeed["data"]; hasData {
t.Fatalf("PATCH /feeds/{id} should be flat PresentFeed JSON, got envelope: %v", patchFeed)
}
if patchFeed["name"] != "Main catalog XML (Nordic)" {
t.Fatalf("patched feed=%v", patchFeed)
}
rec = do(http.MethodPut, "/api/v1/feeds/"+feedID+"/mappings",
`{"mappings":{"title":"g:title","gtin":"g:gtin","description":"g:description"}}`)
if rec.Code != http.StatusOK {
t.Fatalf("put mappings status=%d body=%s", rec.Code, rec.Body.String())
}
// --- Export feeds CRUD ---
rec = do(http.MethodPost, "/api/v1/export-feeds", `{
"name":"Google Shopping XML",
"format":"xml",
"source_feed_id":`+fmt.Sprintf("%q", feedID)+`
}`)
if rec.Code != http.StatusCreated {
t.Fatalf("create export feed status=%d body=%s", rec.Code, rec.Body.String())
}
expEnv := decode(t, rec)
expData, _ := expEnv["data"].(map[string]any)
if expData["name"] != "Google Shopping XML" || expData["format"] != "xml" {
t.Fatalf("create export=%v", expData)
}
if expData["public_token"] == nil || expData["public_token"] == "" {
t.Fatalf("export missing public_token: %v", expData)
}
oldPublicToken := fmt.Sprint(expData["public_token"])
expID := fmt.Sprint(expData["id"])
rec = do(http.MethodPost, "/api/v1/export-feeds/"+expID+"/rotate-token", "")
if rec.Code != http.StatusOK {
t.Fatalf("rotate export token status=%d body=%s", rec.Code, rec.Body.String())
}
rotated := decode(t, rec)
newPublicToken := fmt.Sprint(rotated["public_token"])
if newPublicToken == "" || newPublicToken == "<nil>" || newPublicToken == oldPublicToken {
t.Fatalf("rotate did not replace public_token old=%q new=%q body=%v", oldPublicToken, newPublicToken, rotated)
}
if len(newPublicToken) != 64 {
t.Fatalf("rotated public_token len=%d want 64", len(newPublicToken))
}
rec = do(http.MethodGet, "/api/v1/export-feeds?page=1&limit=25", "")
if rec.Code != http.StatusOK {
t.Fatalf("list export feeds status=%d body=%s", rec.Code, rec.Body.String())
}
listExp := decode(t, rec)
if _, ok := listExp["data"]; !ok {
t.Fatalf("list export missing data: %v", listExp)
}
rec = do(http.MethodGet, "/api/v1/export-feeds/"+expID, "")
if rec.Code != http.StatusOK {
t.Fatalf("get export feed status=%d body=%s", rec.Code, rec.Body.String())
}
getExp := decode(t, rec)
if _, hasData := getExp["data"]; hasData {
t.Fatalf("GET /export-feeds/{id} should be flat JSON, got envelope: %v", getExp)
}
rec = do(http.MethodPatch, "/api/v1/export-feeds/"+expID, `{"name":"Google Shopping XML v2","is_active":true}`)
if rec.Code != http.StatusOK {
t.Fatalf("patch export status=%d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodPut, "/api/v1/export-feeds/"+expID+"/template",
`{"template":{"root":"rss/channel","item":"item","mappings":{"title":"title"}}}`)
if rec.Code != http.StatusOK {
t.Fatalf("put template status=%d body=%s", rec.Code, rec.Body.String())
}
// --- Products list + seed get/patch ---
rec = do(http.MethodGet, "/api/v1/products?page=1&limit=25", "")
if rec.Code != http.StatusOK {
t.Fatalf("list products status=%d body=%s", rec.Code, rec.Body.String())
}
prodList := decode(t, rec)
if _, ok := prodList["data"]; !ok {
t.Fatalf("list products missing data: %v", prodList)
}
productID := uuid.New()
_, err = pg.Exec(ctx, `
INSERT INTO processed_products (
id, company_id, product_id, name, category, description, status,
processed_name, processed_description, attributes, processed_attributes, feed_id
) VALUES (
$1, $2, $3, $4, $5, $6, 'completed',
$7, $8, '{}'::jsonb, '{}'::jsonb, $9
)`,
productID, companyID, "SKU-1001", "Wireless earbuds", catUnique,
"Original catalog description",
"Acme Wireless Earbuds ANC Black",
"Noise-cancelling wireless earbuds with 24h battery life.",
uuid.MustParse(feedID),
)
if err != nil {
t.Fatalf("seed processed product: %v", err)
}
rec = do(http.MethodGet, "/api/v1/products/"+productID.String(), "")
if rec.Code != http.StatusOK {
t.Fatalf("get product status=%d body=%s", rec.Code, rec.Body.String())
}
gotProd := decode(t, rec)
if _, hasData := gotProd["data"]; hasData {
t.Fatalf("GET /products/{id} should be flat JSON, got envelope: %v", gotProd)
}
if fmt.Sprint(gotProd["product_id"]) != "SKU-1001" {
t.Fatalf("get product=%v", gotProd)
}
rec = do(http.MethodPatch, "/api/v1/products/"+productID.String(),
`{"processed_name":"Acme Wireless Earbuds ANC Midnight","status":"completed"}`)
if rec.Code != http.StatusOK {
t.Fatalf("patch product status=%d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodGet, "/api/v1/products/quality?page=1&limit=25", "")
if rec.Code != http.StatusOK {
t.Fatalf("list quality status=%d body=%s", rec.Code, rec.Body.String())
}
// --- Campaigns / marketing calendar ---
rec = do(http.MethodGet, "/api/v1/campaigns?year=2026", "")
if rec.Code != http.StatusOK {
t.Fatalf("list campaigns status=%d body=%s", rec.Code, rec.Body.String())
}
camp := decode(t, rec)
campData, _ := camp["data"].(map[string]any)
if campData["year"].(float64) != 2026 {
t.Fatalf("campaigns data=%v", campData)
}
presets, _ := campData["presets"].([]any)
if len(presets) == 0 {
t.Fatalf("expected seasonal presets, got %v", campData)
}
rec = do(http.MethodGet, "/api/v1/marketing/calendar?year=2026", "")
if rec.Code != http.StatusOK {
t.Fatalf("marketing calendar status=%d body=%s", rec.Code, rec.Body.String())
}
cal := decode(t, rec)
if _, hasData := cal["data"]; hasData {
t.Fatalf("GET /marketing/calendar should be flat JSON per OpenAPI, got envelope: %v", cal)
}
rec = do(http.MethodPost, "/api/v1/campaigns/prepare",
`{"preset_id":"black_friday","year":2026,"format":"csv"}`)
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
t.Fatalf("prepare campaign status=%d body=%s", rec.Code, rec.Body.String())
}
prep := decode(t, rec)
prepData, _ := prep["data"].(map[string]any)
if prepData["preset_id"] != "black_friday" {
t.Fatalf("prepare data=%v", prepData)
}
// --- Deletes (reverse dependency order) ---
rec = do(http.MethodDelete, "/api/v1/export-feeds/"+expID, "")
if rec.Code != http.StatusOK {
t.Fatalf("delete export status=%d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodDelete, "/api/v1/feeds/"+feedID, "")
if rec.Code != http.StatusOK {
t.Fatalf("delete feed status=%d body=%s", rec.Code, rec.Body.String())
}
delFeed := decode(t, rec)
if delFeed["deleted"] != true {
t.Fatalf("delete feed response=%v", delFeed)
}
rec = do(http.MethodDelete, "/api/v1/attributes/"+attrID, "")
if rec.Code != http.StatusOK {
t.Fatalf("delete attribute status=%d body=%s", rec.Code, rec.Body.String())
}
delAttr := decode(t, rec)
delAttrData, _ := delAttr["data"].(map[string]any)
if delAttrData["message"] != "Attribute deleted successfully" {
t.Fatalf("delete attribute=%v", delAttr)
}
rec = do(http.MethodDelete, "/api/v1/categories/"+childUnique, "")
if rec.Code != http.StatusOK {
t.Fatalf("delete child category status=%d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodDelete, "/api/v1/categories/"+catUnique, "")
if rec.Code != http.StatusOK {
t.Fatalf("delete category status=%d body=%s", rec.Code, rec.Body.String())
}
delCat := decode(t, rec)
delCatData, _ := delCat["data"].(map[string]any)
if delCatData["message"] != "Category deleted successfully" {
t.Fatalf("delete category=%v", delCat)
}
// Confirm 404 after delete
rec = do(http.MethodGet, "/api/v1/feeds/"+feedID, "")
if rec.Code != http.StatusNotFound {
t.Fatalf("get deleted feed status=%d want 404 body=%s", rec.Code, rec.Body.String())
}
}
func mountV1DomainTestRouter(s *Server) http.Handler {
r := chi.NewRouter()
r.Route("/api/v1", func(r chi.Router) {
r.Get("/products", s.handleV1ListProducts)
r.Get("/products/quality", s.handleV1ListProductQuality)
r.Get("/products/{id}", s.handleGetProduct)
r.Patch("/products/{id}", s.handleUpdateProduct)
r.Get("/marketing/calendar", s.handleGetMarketingCalendar)
r.Post("/marketing/calendar/prepare", s.handlePrepareMarketingCalendar)
r.Get("/campaigns", s.handleV1ListCampaigns)
r.Post("/campaigns/prepare", s.handleV1PrepareCampaign)
r.Get("/categories", s.handleV1ListCategories)
r.Post("/categories", s.handleV1CreateCategory)
r.Post("/categories/create", s.handleV1CreateCategory)
r.Get("/categories/{id}", s.handleGetCategory)
r.Patch("/categories/{id}", s.handleUpdateCategory)
r.Delete("/categories/{id}", s.handleV1DeleteCategory)
r.Get("/attributes", s.handleV1ListAttributes)
r.Post("/attributes", s.handleV1CreateAttribute)
r.Post("/attributes/create", s.handleV1CreateAttribute)
r.Patch("/attributes/{id}", s.handleUpdateAttribute)
r.Delete("/attributes/{id}", s.handleV1DeleteAttribute)
r.Get("/feeds", s.handleV1ListFeeds)
r.Post("/feeds", s.handleV1CreateFeed)
r.Get("/feeds/{id}", s.handleV1GetFeed)
r.Patch("/feeds/{id}", s.handleUpdateFeed)
r.Delete("/feeds/{id}", s.handleDeleteFeed)
r.Get("/feeds/{id}/mappings", s.handleGetFeedMappings)
r.Put("/feeds/{id}/mappings", s.handlePutFeedMappings)
r.Get("/export-feeds", s.handleV1ListExportFeeds)
r.Post("/export-feeds", s.handleV1CreateExportFeed)
r.Get("/export-feeds/{id}", s.handleGetExportFeed)
r.Patch("/export-feeds/{id}", s.handleUpdateExportFeed)
r.Put("/export-feeds/{id}/template", s.handleUpdateExportFeedTemplate)
r.Delete("/export-feeds/{id}", s.handleDeleteExportFeed)
r.Post("/export-feeds/{id}/rotate-token", s.handleRotateExportFeedPublicToken)
})
return r
}
func TestV1OpenAPIDocumentsDomainCRUDSurface(t *testing.T) {
t.Parallel()
body := string(v1OpenAPIYAML)
needles := []string{
"/categories:",
"/attributes:",
"/feeds:",
"/export-feeds:",
"/export-feeds/{id}/rotate-token:",
"/campaigns:",
"/marketing/calendar:",
"/products:",
"/feeds/{id}/sync-process-sample:",
"Flat category JSON",
"flat PresentFeed",
"flat ProcessedProduct",
"CategoryDetail",
"FeedDeleted",
"FeedMappings",
"is_active:",
}
for _, n := range needles {
if !strings.Contains(body, n) {
t.Fatalf("openapi missing %q", n)
}
}
}
@@ -0,0 +1,264 @@
package httpapi
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
"github.com/descrybe/descrybe-v2/apps/api/internal/marketing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// presentV1ExportFeed shapes an export feed for the legacy public API
// (presentExportFeed + v2 public_token extras).
func presentV1ExportFeed(item map[string]any) map[string]any {
if item == nil {
return map[string]any{}
}
out := map[string]any{
"id": item["id"],
"name": item["name"],
"format": item["format"],
"root_xpath": nil,
"item_xpath": nil,
"mappings": map[string]any{},
"structure": nil,
"last_generated_at": item["last_generated_at"],
"created_at": item["created_at"],
"updated_at": item["updated_at"],
"public_token": item["public_token"],
"is_active": item["is_active"],
"source_feed_id": item["source_feed_id"],
}
if v, ok := item["template"]; ok && v != nil {
out["structure"] = v
}
if v, ok := item["filters"]; ok && v != nil {
out["filters"] = v
}
if token, _ := item["public_token"].(string); token != "" {
format := strings.ToLower(strings.TrimSpace(fmt.Sprint(item["format"])))
ext := "xml"
if format == "csv" {
ext = "csv"
}
path := "/api/public/export-feeds/" + token + "." + ext
// Only advertise the matching extension — wrong-format URLs 404 and must not
// be suggested (also avoids encouraging token-existence probes).
out["public_urls"] = map[string]string{
ext: path,
"token": path,
}
}
return out
}
func (s *Server) handleV1ListExportFeeds(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
page, limit, offset := ParsePageLimit(r)
items, total, err := s.Feeds.ListExportFeeds(r.Context(), cid, limit, offset)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
out = append(out, presentV1ExportFeed(item))
}
v1OK(w, http.StatusOK, out, map[string]any{
"page": page,
"limit": limit,
"total": total,
})
}
func (s *Server) handleV1CreateExportFeed(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var body struct {
Name string `json:"name"`
Format string `json:"format"`
SourceFeedID *string `json:"source_feed_id"`
Template any `json:"template"`
Structure any `json:"structure"`
Mappings any `json:"mappings"`
Filters any `json:"filters"`
RootXpath *string `json:"root_xpath"`
ItemXpath *string `json:"item_xpath"`
OutputPath *string `json:"output_path"`
AttributeExportMode any `json:"attribute_export_mode"`
}
if err := DecodeJSON(r, &body); err != nil {
v1Err(w, http.StatusBadRequest, "validation_error", "invalid json")
return
}
if strings.TrimSpace(body.Name) == "" || strings.TrimSpace(body.Format) == "" {
v1Err(w, http.StatusBadRequest, "validation_error", "Missing required fields: name, format")
return
}
tpl := body.Template
if tpl == nil {
tpl = body.Structure
}
if tpl == nil && body.Mappings != nil {
tpl = map[string]any{"mappings": body.Mappings}
}
if tpl == nil && (body.RootXpath != nil || body.ItemXpath != nil) {
m := map[string]any{}
if body.RootXpath != nil {
m["root"] = *body.RootXpath
}
if body.ItemXpath != nil {
m["item"] = *body.ItemXpath
}
tpl = m
}
item, err := s.Feeds.CreateExportFeed(r.Context(), cid, feeds.CreateExportInput{
Name: body.Name, SourceFeedID: body.SourceFeedID, Format: body.Format,
Template: tpl, Filters: body.Filters,
})
if err != nil {
if msg, ok := feeds.ClientError(err); ok {
v1Err(w, http.StatusBadRequest, "validation_error", msg)
return
}
ClientOrLog(w, http.StatusBadRequest, "could not create export feed", err, feeds.ClientError)
return
}
v1OK(w, http.StatusCreated, presentV1ExportFeed(item), nil)
}
func (s *Server) handleV1GenerateExportFeed(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
v1Err(w, http.StatusBadRequest, "validation_error", "invalid id")
return
}
feed, err := s.Feeds.GetExportFeed(r.Context(), cid, id)
if err != nil {
v1Err(w, http.StatusNotFound, "not_found", "Export feed not found")
return
}
result, err := s.Feeds.GenerateExportFeed(r.Context(), cid, id)
if err != nil {
if msg, ok := feeds.ClientError(err); ok {
v1Err(w, http.StatusBadRequest, "generation_failed", msg)
return
}
v1Err(w, http.StatusInternalServerError, "generation_failed", "Failed to generate export feed")
return
}
format := strings.ToLower(strings.TrimSpace(fmt.Sprint(feed["format"])))
if format == "" {
format = strings.ToLower(strings.TrimSpace(fmt.Sprint(result["format"])))
}
ext := "xml"
if format == "csv" {
ext = "csv"
}
token, _ := feed["public_token"].(string)
downloadURL := fmt.Sprintf("/api/export-feeds/%s/%s", id.String(), ext)
if token != "" {
downloadURL = fmt.Sprintf("/api/public/export-feeds/%s.%s", token, ext)
}
v1OK(w, http.StatusOK, map[string]any{
"generated": true,
"format": format,
"filePath": nil,
"downloadUrl": downloadURL,
"products_exported": result["products_exported"],
"last_generated_at": result["last_generated_at"],
"status": result["status"],
}, nil)
}
// handleV1ListCampaigns is the legacy alias for GET /marketing/calendar
// (seasonal export prep — not email /api/campaigns).
func (s *Server) handleV1ListCampaigns(w http.ResponseWriter, r *http.Request) {
payload, status, errCode, errMsg := s.v1MarketingCalendar(r)
if errMsg != "" {
v1Err(w, status, errCode, errMsg)
return
}
v1OK(w, http.StatusOK, payload, nil)
}
// handleV1PrepareCampaign is the legacy alias for POST /marketing/calendar/prepare.
func (s *Server) handleV1PrepareCampaign(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var body struct {
PresetID string `json:"preset_id"`
Year int `json:"year"`
Format string `json:"format"`
ForceNew bool `json:"force_new"`
}
if err := DecodeJSON(r, &body); err != nil {
v1Err(w, http.StatusBadRequest, "validation_error", "invalid json")
return
}
campaign, err := s.marketingService().PrepareCampaign(r.Context(), cid, marketing.PrepareInput{
PresetID: marketing.PresetID(body.PresetID),
Year: body.Year,
Format: body.Format,
ForceNew: body.ForceNew,
})
if err != nil {
if msg, ok := marketing.ClientError(err); ok {
v1Err(w, http.StatusBadRequest, "validation_error", msg)
return
}
ClientOrLog(w, http.StatusBadRequest, "could not prepare campaign", err, marketing.ClientError)
return
}
status := http.StatusOK
if campaign.Created {
status = http.StatusCreated
}
v1OK(w, status, map[string]any{
"preset_id": campaign.PresetID,
"name": campaign.Name,
"start_date": campaign.StartDate,
"end_date": campaign.EndDate,
"year": campaign.Year,
"export_feed_id": campaign.ExportFeedID,
"export_feed_name": campaign.ExportFeedName,
"created": campaign.Created,
}, nil)
}
func (s *Server) v1MarketingCalendar(r *http.Request) (payload map[string]any, status int, errCode, errMsg string) {
cid, _ := CompanyIDFromContext(r.Context())
year := time.Now().UTC().Year()
if y := r.URL.Query().Get("year"); y != "" {
parsed, err := strconv.Atoi(y)
if err != nil || parsed < 2000 || parsed > 2100 {
return nil, http.StatusBadRequest, "validation_error", "Invalid year"
}
year = parsed
}
prepared, err := s.marketingService().ListPreparedCampaigns(r.Context(), cid)
if err != nil {
return nil, http.StatusInternalServerError, "list_failed", "list failed"
}
preparedOut := make([]map[string]any, 0, len(prepared))
for _, c := range prepared {
preparedOut = append(preparedOut, map[string]any{
"preset_id": c.PresetID,
"name": c.Name,
"start_date": c.StartDate,
"end_date": c.EndDate,
"year": c.Year,
"export_feed_id": c.ExportFeedID,
"export_feed_name": c.ExportFeedName,
})
}
return map[string]any{
"year": year,
"presets": marketing.ListPresets(year),
"prepared": preparedOut,
}, http.StatusOK, "", ""
}
@@ -0,0 +1,66 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestOKEnvelope(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
v1OK(rec, http.StatusOK, []string{"a"}, map[string]any{"page": 1, "limit": 25, "total": 1})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if _, ok := body["data"]; !ok {
t.Fatalf("missing data: %#v", body)
}
meta, _ := body["meta"].(map[string]any)
if meta["page"] != float64(1) || meta["limit"] != float64(25) {
t.Fatalf("meta = %#v", body["meta"])
}
}
func TestPresentV1ExportFeedPublicURLs(t *testing.T) {
t.Parallel()
out := presentV1ExportFeed(map[string]any{
"id": "799ba83a-6a7e-4e1d-b5de-c02182aacec5",
"name": "XML",
"format": "xml",
"public_token": "9bf8905985e4c2bb2e5f6a0f89ddc1b6",
"is_active": true,
})
urls, _ := out["public_urls"].(map[string]string)
if urls["xml"] != "/api/public/export-feeds/9bf8905985e4c2bb2e5f6a0f89ddc1b6.xml" {
t.Fatalf("public_urls = %#v", urls)
}
if urls["token"] != urls["xml"] {
t.Fatalf("token url should match format: %#v", urls)
}
if _, hasCSV := urls["csv"]; hasCSV {
t.Fatalf("must not advertise wrong-format url: %#v", urls)
}
if out["root_xpath"] != nil || out["mappings"] == nil {
t.Fatalf("legacy fields missing: %#v", out)
}
}
func TestParsePageLimitOffset(t *testing.T) {
t.Parallel()
r := httptest.NewRequest(http.MethodGet, "/x?page=2&limit=10", nil)
page, limit, offset := ParsePageLimitOffset(r)
if page != 2 || limit != 10 || offset != 10 {
t.Fatalf("page=%d limit=%d offset=%d", page, limit, offset)
}
r2 := httptest.NewRequest(http.MethodGet, "/x?offset=5&limit=10", nil)
page, limit, offset = ParsePageLimitOffset(r2)
if page != 1 || limit != 10 || offset != 5 {
t.Fatalf("offset mode: page=%d limit=%d offset=%d", page, limit, offset)
}
}

Some files were not shown because too many files have changed in this diff Show More