Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,11 +230,13 @@ func (app *application) mount() http.Handler {
r.Route("/sponsors", func(r chi.Router) {
r.Get("/", app.listSponsorsHandler)

// TODO: Protect Under a AdminSponsorEditPermissionMiddleware
r.Post("/", app.createSponsorHandler)
r.Put("/{sponsorID}", app.updateSponsorHandler)
r.Delete("/{sponsorID}", app.deleteSponsorHandler)
r.Put("/{sponsorID}/logo", app.uploadLogoHandler)
r.Group(func(r chi.Router) {
r.Use(app.AdminSponsorEditPermissionMiddleware)
r.Post("/", app.createSponsorHandler)
r.Put("/{sponsorID}", app.updateSponsorHandler)
r.Delete("/{sponsorID}", app.deleteSponsorHandler)
r.Put("/{sponsorID}/logo", app.uploadLogoHandler)
})
})
})
})
Expand All @@ -254,6 +256,8 @@ func (app *application) mount() http.Handler {
r.Put("/review-assignment-toggle", app.setReviewAssignmentToggle)
r.Get("/admin-schedule-edit-toggle", app.getAdminScheduleEditToggle)
r.Post("/admin-schedule-edit-toggle", app.setAdminScheduleEditToggle)
r.Get("/admin-sponsor-edit-toggle", app.getAdminSponsorEditToggle)
r.Post("/admin-sponsor-edit-toggle", app.setAdminSponsorEditToggle)
r.Get("/hackathon-date-range", app.getHackathonDateRange)
r.Post("/hackathon-date-range", app.setHackathonDateRange)
r.Put("/scan-types", app.updateScanTypesHandler)
Expand Down
28 changes: 28 additions & 0 deletions cmd/api/middlewares.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,31 @@ func (app *application) ApplicationsEnabledMiddleware(next http.Handler) http.Ha
next.ServeHTTP(w, r)
})
}

func (app *application) AdminSponsorEditPermissionMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r.Context())
if user == nil {
app.unauthorizedErrorResponse(w, r, fmt.Errorf("user not in context"))
return
}

if user.Role == store.RoleSuperAdmin {
next.ServeHTTP(w, r)
return
}

enabled, err := app.store.Settings.GetAdminSponsorEditEnabled(r.Context())
if err != nil {
app.internalServerError(w, r, err)
return
}

if user.Role == store.RoleAdmin && !enabled {
app.forbiddenResponse(w, r, fmt.Errorf("admin sponsor editing is disabled"))
return
}

next.ServeHTTP(w, r)
})
}
70 changes: 70 additions & 0 deletions cmd/api/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ type AdminScheduleEditToggleResponse struct {
Enabled bool `json:"enabled"`
}

type SetAdminSponsorEditTogglePayload struct {
Enabled bool `json:"enabled"`
}

type AdminSponsorEditToggleResponse struct {
Enabled bool `json:"enabled"`
}

type SetHackathonDateRangePayload struct {
StartDate string `json:"start_date" validate:"required"`
EndDate string `json:"end_date" validate:"required"`
Expand Down Expand Up @@ -323,6 +331,68 @@ func (app *application) setAdminScheduleEditToggle(w http.ResponseWriter, r *htt
}
}

// getAdminSponsorEditToggle returns whether admins can edit sponsors
//
// @Summary Get admin sponsor edit state (Super Admin)
// @Description Returns whether users with admin role can create, update, and delete sponsors
// @Tags superadmin/settings
// @Produce json
// @Success 200 {object} AdminSponsorEditToggleResponse
// @Failure 401 {object} object{error=string}
// @Failure 403 {object} object{error=string}
// @Failure 500 {object} object{error=string}
// @Security CookieAuth
// @Router /superadmin/settings/admin-sponsor-edit-toggle [get]
func (app *application) getAdminSponsorEditToggle(w http.ResponseWriter, r *http.Request) {
enabled, err := app.store.Settings.GetAdminSponsorEditEnabled(r.Context())
if err != nil {
app.internalServerError(w, r, err)
return
}

response := AdminSponsorEditToggleResponse{
Enabled: enabled,
}

if err := app.jsonResponse(w, http.StatusOK, response); err != nil {
app.internalServerError(w, r, err)
}
}

// setAdminSponsorEditToggle updates whether admins can edit sponsors
//
// @Summary Set admin sponsor edit state (Super Admin)
// @Description Updates whether users with admin role can create, update, and delete sponsors
// @Tags superadmin/settings
// @Accept json
// @Produce json
// @Param enabled body SetAdminSponsorEditTogglePayload true "Admin sponsor editing enabled state"
// @Success 200 {object} AdminSponsorEditToggleResponse
// @Failure 400 {object} object{error=string}
// @Failure 401 {object} object{error=string}
// @Failure 403 {object} object{error=string}
// @Failure 500 {object} object{error=string}
// @Security CookieAuth
// @Router /superadmin/settings/admin-sponsor-edit-toggle [post]
func (app *application) setAdminSponsorEditToggle(w http.ResponseWriter, r *http.Request) {
var req SetAdminSponsorEditTogglePayload
if err := readJSON(w, r, &req); err != nil {
app.badRequestResponse(w, r, err)
return
}

if err := app.store.Settings.SetAdminSponsorEditEnabled(r.Context(), req.Enabled); err != nil {
app.internalServerError(w, r, err)
return
}

response := AdminSponsorEditToggleResponse(req)

if err := app.jsonResponse(w, http.StatusOK, response); err != nil {
app.internalServerError(w, r, err)
}
}

// getHackathonDateRange returns hackathon start/end dates
//
// @Summary Get hackathon date range (Super Admin)
Expand Down
74 changes: 74 additions & 0 deletions cmd/api/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,80 @@ func TestSetAdminScheduleEditToggle(t *testing.T) {
})
}

func TestGetAdminSponsorEditToggle(t *testing.T) {
app := newTestApplication(t)
mockSettings := app.store.Settings.(*store.MockSettingsStore)

t.Run("should return current value", func(t *testing.T) {
mockSettings.On("GetAdminSponsorEditEnabled").Return(true, nil).Once()

req, err := http.NewRequest(http.MethodGet, "/", nil)
require.NoError(t, err)
req = setUserContext(req, newSuperAdminUser())

rr := executeRequest(req, http.HandlerFunc(app.getAdminSponsorEditToggle))
checkResponseCode(t, http.StatusOK, rr.Code)

var body struct {
Data AdminSponsorEditToggleResponse `json:"data"`
}
err = json.NewDecoder(rr.Body).Decode(&body)
require.NoError(t, err)
assert.True(t, body.Data.Enabled)

mockSettings.AssertExpectations(t)
})
}

func TestSetAdminSponsorEditToggle(t *testing.T) {
app := newTestApplication(t)
mockSettings := app.store.Settings.(*store.MockSettingsStore)

t.Run("should set enabled=true", func(t *testing.T) {
mockSettings.On("SetAdminSponsorEditEnabled", true).Return(nil).Once()

body := `{"enabled":true}`
req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req = setUserContext(req, newSuperAdminUser())

rr := executeRequest(req, http.HandlerFunc(app.setAdminSponsorEditToggle))
checkResponseCode(t, http.StatusOK, rr.Code)

var respBody struct {
Data AdminSponsorEditToggleResponse `json:"data"`
}
err = json.NewDecoder(rr.Body).Decode(&respBody)
require.NoError(t, err)
assert.True(t, respBody.Data.Enabled)

mockSettings.AssertExpectations(t)
})

t.Run("should set enabled=false", func(t *testing.T) {
mockSettings.On("SetAdminSponsorEditEnabled", false).Return(nil).Once()

body := `{"enabled":false}`
req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req = setUserContext(req, newSuperAdminUser())

rr := executeRequest(req, http.HandlerFunc(app.setAdminSponsorEditToggle))
checkResponseCode(t, http.StatusOK, rr.Code)

var respBody struct {
Data AdminSponsorEditToggleResponse `json:"data"`
}
err = json.NewDecoder(rr.Body).Decode(&respBody)
require.NoError(t, err)
assert.False(t, respBody.Data.Enabled)

mockSettings.AssertExpectations(t)
})
}

func TestGetHackathonDateRange(t *testing.T) {
app := newTestApplication(t)
mockSettings := app.store.Settings.(*store.MockSettingsStore)
Expand Down
120 changes: 120 additions & 0 deletions cmd/api/sponsors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,3 +331,123 @@ func TestUploadLogo(t *testing.T) {
mockSponsors.AssertExpectations(t)
})
}

func protectedSponsorMutationRouter(app *application) chi.Router {
r := chi.NewRouter()
r.With(app.AdminSponsorEditPermissionMiddleware).Post("/", app.createSponsorHandler)
r.With(app.AdminSponsorEditPermissionMiddleware).Put("/{sponsorID}", app.updateSponsorHandler)
r.With(app.AdminSponsorEditPermissionMiddleware).Delete("/{sponsorID}", app.deleteSponsorHandler)
r.With(app.AdminSponsorEditPermissionMiddleware).Put("/{sponsorID}/logo", app.uploadLogoHandler)
return r
}

func TestSponsorMutationPermission(t *testing.T) {
t.Run("admin receives 403 for create when admin sponsor edits are disabled", func(t *testing.T) {
app := newTestApplication(t)
mockSettings := app.store.Settings.(*store.MockSettingsStore)
r := protectedSponsorMutationRouter(app)

mockSettings.On("GetAdminSponsorEditEnabled").Return(false, nil).Once()

body := `{"name":"Acme Corp","tier":"Gold","website_url":"https://acme.com","description":"A sponsor."}`
req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req = setUserContext(req, newAdminUser())

rr := executeRequest(req, r)
checkResponseCode(t, http.StatusForbidden, rr.Code)
mockSettings.AssertExpectations(t)
})

t.Run("admin receives 403 for update when admin sponsor edits are disabled", func(t *testing.T) {
app := newTestApplication(t)
mockSettings := app.store.Settings.(*store.MockSettingsStore)
r := protectedSponsorMutationRouter(app)

mockSettings.On("GetAdminSponsorEditEnabled").Return(false, nil).Once()

body := `{"name":"Updated Corp","tier":"Silver"}`
req, err := http.NewRequest(http.MethodPut, "/sponsor-1", strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req = setUserContext(req, newAdminUser())

rr := executeRequest(req, r)
checkResponseCode(t, http.StatusForbidden, rr.Code)
mockSettings.AssertExpectations(t)
})

t.Run("admin receives 403 for delete when admin sponsor edits are disabled", func(t *testing.T) {
app := newTestApplication(t)
mockSettings := app.store.Settings.(*store.MockSettingsStore)
r := protectedSponsorMutationRouter(app)

mockSettings.On("GetAdminSponsorEditEnabled").Return(false, nil).Once()

req, err := http.NewRequest(http.MethodDelete, "/sponsor-1", nil)
require.NoError(t, err)
req = setUserContext(req, newAdminUser())

rr := executeRequest(req, r)
checkResponseCode(t, http.StatusForbidden, rr.Code)
mockSettings.AssertExpectations(t)
})

t.Run("admin receives 403 for logo upload when admin sponsor edits are disabled", func(t *testing.T) {
app := newTestApplication(t)
mockSettings := app.store.Settings.(*store.MockSettingsStore)
r := protectedSponsorMutationRouter(app)

mockSettings.On("GetAdminSponsorEditEnabled").Return(false, nil).Once()

body := `{"logo_data":"aGVsbG8=","content_type":"image/png"}`
req, err := http.NewRequest(http.MethodPut, "/sponsor-1/logo", strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req = setUserContext(req, newAdminUser())

rr := executeRequest(req, r)
checkResponseCode(t, http.StatusForbidden, rr.Code)
mockSettings.AssertExpectations(t)
})

t.Run("admin can create when admin sponsor edits are enabled", func(t *testing.T) {
app := newTestApplication(t)
mockSponsors := app.store.Sponsors.(*store.MockSponsorsStore)
mockSettings := app.store.Settings.(*store.MockSettingsStore)
r := protectedSponsorMutationRouter(app)

mockSettings.On("GetAdminSponsorEditEnabled").Return(true, nil).Once()
mockSponsors.On("Create", mock.AnythingOfType("*store.Sponsor")).Return(nil).Once()

body := `{"name":"Acme Corp","tier":"Gold","website_url":"https://acme.com","description":"A sponsor."}`
req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req = setUserContext(req, newAdminUser())

rr := executeRequest(req, r)
checkResponseCode(t, http.StatusCreated, rr.Code)
mockSettings.AssertExpectations(t)
mockSponsors.AssertExpectations(t)
})

t.Run("super admin can create when admin sponsor edits are disabled", func(t *testing.T) {
app := newTestApplication(t)
mockSponsors := app.store.Sponsors.(*store.MockSponsorsStore)
r := protectedSponsorMutationRouter(app)

mockSponsors.On("Create", mock.AnythingOfType("*store.Sponsor")).Return(nil).Once()

body := `{"name":"Acme Corp","tier":"Gold","website_url":"https://acme.com","description":"A sponsor."}`
req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req = setUserContext(req, newSuperAdminUser())

rr := executeRequest(req, r)
checkResponseCode(t, http.StatusCreated, rr.Code)
mockSponsors.AssertExpectations(t)
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DELETE FROM settings WHERE key = 'admin_sponsor_edit_enabled';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
INSERT INTO settings (key, value) VALUES ('admin_sponsor_edit_enabled', 'true'::jsonb)
ON CONFLICT (key) DO NOTHING;
10 changes: 10 additions & 0 deletions internal/store/mock_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,16 @@ func (m *MockSettingsStore) SetAdminScheduleEditEnabled(ctx context.Context, ena
return args.Error(0)
}

func (m *MockSettingsStore) GetAdminSponsorEditEnabled(ctx context.Context) (bool, error) {
args := m.Called()
return args.Bool(0), args.Error(1)
}

func (m *MockSettingsStore) SetAdminSponsorEditEnabled(ctx context.Context, enabled bool) error {
args := m.Called(enabled)
return args.Error(0)
}

func (m *MockSettingsStore) GetHackathonDateRange(ctx context.Context) (HackathonDateRange, error) {
args := m.Called()
if args.Get(0) == nil {
Expand Down
Loading
Loading