From 888d06cbff5fefe908047a86c997607aadb931d0 Mon Sep 17 00:00:00 2001 From: Sreevasan Date: Mon, 20 Apr 2026 14:17:18 -0500 Subject: [PATCH 1/3] Added middleware along with adjusting settings.go --- cmd/api/api.go | 11 +++++---- cmd/api/middlewares.go | 28 +++++++++++++++++++++++ internal/store/settings.go | 46 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index 76296357..3a31ee42 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -231,10 +231,13 @@ func (app *application) mount() http.Handler { 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) + }) }) }) }) diff --git a/cmd/api/middlewares.go b/cmd/api/middlewares.go index 5756306f..076d2680 100644 --- a/cmd/api/middlewares.go +++ b/cmd/api/middlewares.go @@ -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) + }) +} diff --git a/internal/store/settings.go b/internal/store/settings.go index 52e79564..beebe881 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -554,3 +554,49 @@ func (s *SettingsStore) SetApplicationsEnabled(ctx context.Context, enabled bool _, err = s.db.ExecContext(ctx, query, SettingsKeyApplicationsEnabled, string(jsonValue)) return err } + +func (s *SettingsStore) GetAdminSponsorEditEnabled(ctx context.Context) (bool, error) { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + query := ` + SELECT value + FROM settings + WHERE key = 'admin_sponsor_edit_enabled' + ` + + var value []byte + err := s.db.QueryRowContext(ctx, query).Scan(&value) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return true, nil + } + return false, err + } + + var enabled bool + if err := json.Unmarshal(value, &enabled); err != nil { + return false, err + } + + return enabled, nil +} + +func (s *SettingsStore) SetAdminSponsorEditEnabled(ctx context.Context, enabled bool) error { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + jsonValue, err := json.Marshal(enabled) + if err != nil { + return err + } + + query := ` + INSERT INTO settings (key, value) + VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + ` + + _, err = s.db.ExecContext(ctx, query, "admin_sponsor_edit_enabled", string(jsonValue)) + return err +} From 325e3d306614840adfa56eb181320a8d0afc77f3 Mon Sep 17 00:00:00 2001 From: Sreevasan Date: Thu, 23 Apr 2026 17:23:26 -0500 Subject: [PATCH 2/3] added the types, handlers, and routes --- cmd/api/api.go | 3 +- cmd/api/settings.go | 70 ++++++++++++++++++++++++++++++++++++ internal/store/mock_store.go | 10 ++++++ internal/store/storage.go | 2 ++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index 3a31ee42..30ec6bd4 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -230,7 +230,6 @@ func (app *application) mount() http.Handler { r.Route("/sponsors", func(r chi.Router) { r.Get("/", app.listSponsorsHandler) - // TODO: Protect Under a AdminSponsorEditPermissionMiddleware r.Group(func(r chi.Router) { r.Use(app.AdminSponsorEditPermissionMiddleware) r.Post("/", app.createSponsorHandler) @@ -257,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) diff --git a/cmd/api/settings.go b/cmd/api/settings.go index 053a5ecc..c2ca2f9a 100644 --- a/cmd/api/settings.go +++ b/cmd/api/settings.go @@ -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"` @@ -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) diff --git a/internal/store/mock_store.go b/internal/store/mock_store.go index 0469a60e..16bda49d 100644 --- a/internal/store/mock_store.go +++ b/internal/store/mock_store.go @@ -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 { diff --git a/internal/store/storage.go b/internal/store/storage.go index 7b396d48..e2c4f9de 100644 --- a/internal/store/storage.go +++ b/internal/store/storage.go @@ -55,6 +55,8 @@ type Storage struct { GetScanStats(ctx context.Context) (map[string]int, error) GetApplicationsEnabled(ctx context.Context) (bool, error) SetApplicationsEnabled(ctx context.Context, enabled bool) error + GetAdminSponsorEditEnabled(ctx context.Context) (bool, error) + SetAdminSponsorEditEnabled(ctx context.Context, enabled bool) error } Hackathon interface { Reset(ctx context.Context, resetApplications, resetScans, resetSchedule, resetSettings bool) ([]string, error) From ba2d5b904aaa8ec09d91236a19d054415db1cfdc Mon Sep 17 00:00:00 2001 From: Sreevasan Date: Thu, 30 Apr 2026 13:05:49 -0500 Subject: [PATCH 3/3] Final changes --- cmd/api/settings_test.go | 74 +++++++++++ cmd/api/sponsors_test.go | 120 ++++++++++++++++++ ...5_seed_admin_sponsor_edit_enabled.down.sql | 1 + ...015_seed_admin_sponsor_edit_enabled.up.sql | 2 + internal/store/settings.go | 7 +- 5 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 cmd/migrate/migrations/000015_seed_admin_sponsor_edit_enabled.down.sql create mode 100644 cmd/migrate/migrations/000015_seed_admin_sponsor_edit_enabled.up.sql diff --git a/cmd/api/settings_test.go b/cmd/api/settings_test.go index a6c44c9e..48cde32b 100644 --- a/cmd/api/settings_test.go +++ b/cmd/api/settings_test.go @@ -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) diff --git a/cmd/api/sponsors_test.go b/cmd/api/sponsors_test.go index e6bef09c..6fcd44f2 100644 --- a/cmd/api/sponsors_test.go +++ b/cmd/api/sponsors_test.go @@ -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) + }) +} diff --git a/cmd/migrate/migrations/000015_seed_admin_sponsor_edit_enabled.down.sql b/cmd/migrate/migrations/000015_seed_admin_sponsor_edit_enabled.down.sql new file mode 100644 index 00000000..c9a47ccd --- /dev/null +++ b/cmd/migrate/migrations/000015_seed_admin_sponsor_edit_enabled.down.sql @@ -0,0 +1 @@ +DELETE FROM settings WHERE key = 'admin_sponsor_edit_enabled'; diff --git a/cmd/migrate/migrations/000015_seed_admin_sponsor_edit_enabled.up.sql b/cmd/migrate/migrations/000015_seed_admin_sponsor_edit_enabled.up.sql new file mode 100644 index 00000000..70b15b00 --- /dev/null +++ b/cmd/migrate/migrations/000015_seed_admin_sponsor_edit_enabled.up.sql @@ -0,0 +1,2 @@ +INSERT INTO settings (key, value) VALUES ('admin_sponsor_edit_enabled', 'true'::jsonb) +ON CONFLICT (key) DO NOTHING; diff --git a/internal/store/settings.go b/internal/store/settings.go index beebe881..389fef23 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -18,6 +18,7 @@ const SettingsKeyReviewAssignmentToggle = "review_assignment_toggle" const SettingsKeyScanTypes = "scan_types" const SettingsKeyScanStats = "scan_stats" const SettingsKeyAdminScheduleEditEnabled = "admin_schedule_edit_enabled" +const SettingsKeyAdminSponsorEditEnabled = "admin_sponsor_edit_enabled" const SettingsKeyHackathonDateRange = "hackathon_date_range" const SettingsKeyApplicationsEnabled = "applications_enabled" @@ -562,11 +563,11 @@ func (s *SettingsStore) GetAdminSponsorEditEnabled(ctx context.Context) (bool, e query := ` SELECT value FROM settings - WHERE key = 'admin_sponsor_edit_enabled' + WHERE key = $1 ` var value []byte - err := s.db.QueryRowContext(ctx, query).Scan(&value) + err := s.db.QueryRowContext(ctx, query, SettingsKeyAdminSponsorEditEnabled).Scan(&value) if err != nil { if errors.Is(err, sql.ErrNoRows) { return true, nil @@ -597,6 +598,6 @@ func (s *SettingsStore) SetAdminSponsorEditEnabled(ctx context.Context, enabled ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` - _, err = s.db.ExecContext(ctx, query, "admin_sponsor_edit_enabled", string(jsonValue)) + _, err = s.db.ExecContext(ctx, query, SettingsKeyAdminSponsorEditEnabled, string(jsonValue)) return err }