Files

223 lines
7.4 KiB
Go
Raw Permalink Normal View History

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")
}
})
2026-08-17 11:30:14 +02:00
t.Run("company_owner_member_role", func(t *testing.T) {
t.Parallel()
s := &Server{
testPlatformAdmin: func(context.Context, uuid.UUID) (bool, error) {
return false, nil
},
testCompanyOwner: 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("company owner with membership role=member must be allowed to invite")
}
})
2026-08-14 01:38:34 +02:00
t.Run("impersonated_member_denied", 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")
2026-08-14 01:38:34 +02:00
if s.allowCompanyAdminOrPlatform(w, r.WithContext(ctx)) {
t.Fatal("impersonated member must not retain company-admin powers")
}
})).ServeHTTP(rec, func() *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
return req
}())
2026-08-14 01:38:34 +02:00
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s, want 403", rec.Code, rec.Body.String())
}
})
}