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 TestRequireCompanyAllowsFullStaffWithoutMembership(t *testing.T) { t.Parallel() sm := scs.New() uid := uuid.New() cid := uuid.New() s := &Server{ Sessions: sm, Config: config.Config{}, Auth: &auth.Service{}, testStaffAccess: func(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error) { if userID != uid { t.Fatalf("unexpected user %s", userID) } return auth.ResolveStaffAccess(true, auth.StaffRoleAdmin), nil }, } 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") } var gotCompany uuid.UUID var gotRole string h := LoadSession(sm)(s.RequireSession(s.RequireCompany(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotCompany, _ = CompanyIDFromContext(r.Context()) gotRole, _ = RoleFromContext(r.Context()) w.WriteHeader(http.StatusNoContent) })))) 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.StatusNoContent { t.Fatalf("status = %d, want 204 staff override", rec.Code) } if gotCompany != cid { t.Fatalf("company = %s, want %s", gotCompany, cid) } if gotRole != "admin" { t.Fatalf("role = %q, want admin", gotRole) } } func TestRequireCompanyDeniesDeveloperStaffWithoutMembership(t *testing.T) { t.Parallel() sm := scs.New() uid := uuid.New() cid := uuid.New() s := &Server{ Sessions: sm, Config: config.Config{}, Auth: &auth.Service{}, testStaffAccess: func(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error) { return auth.ResolveStaffAccess(true, auth.StaffRoleDeveloper), nil }, } 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") } h := LoadSession(sm)(s.RequireSession(s.RequireCompany(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })))) 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 for developer without membership", 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) } }