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") } }) }