From 9a17bbd0575cad18c3fcf3fd20bfd80994823b0a Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:09:19 +0100 Subject: [PATCH 1/2] Add feature toggles to TestUtilitiesController --- app/controllers/test_utilities_controller.rb | 36 +++- config/routes.rb | 2 + .../test_utilities_controller_spec.rb | 204 ++++++++++++++++++ spec/requests/test_utilities_spec.rb | 111 ---------- 4 files changed, 234 insertions(+), 119 deletions(-) create mode 100644 spec/requests/test_utilities_controller_spec.rb delete mode 100644 spec/requests/test_utilities_spec.rb diff --git a/app/controllers/test_utilities_controller.rb b/app/controllers/test_utilities_controller.rb index 85fb71525..b3441c6d1 100644 --- a/app/controllers/test_utilities_controller.rb +++ b/app/controllers/test_utilities_controller.rb @@ -2,23 +2,39 @@ class TestUtilitiesController < ApplicationController skip_before_action :verify_authenticity_token + before_action :ensure_allowed + ALLOWED_HOSTS = ['test-editor-api.raspberrypi.org', 'localhost'].freeze Rails.application.load_tasks if Rake::Task.tasks.empty? def reseed - if reseed_allowed? - Rake::Task['test_seeds:destroy'].execute - Rake::Task['test_seeds:create'].execute - render json: { message: 'Database reseeded successfully.' }, status: :ok - else - head :not_found - end + Rake::Task['test_seeds:destroy'].execute + Rake::Task['test_seeds:create'].execute + render json: { message: 'Database reseeded successfully.' }, status: :ok + end + + def enable_feature + Flipper.enable(feature_key) + head :no_content + end + + def disable_feature + Flipper.disable(feature_key) + head :no_content end private - def reseed_allowed? + def ensure_allowed + not_found unless allowed? + end + + def not_found + head :not_found + end + + def allowed? api_key_valid? && host_allowed? end @@ -29,4 +45,8 @@ def api_key_valid? def host_allowed? ALLOWED_HOSTS.include?(request.host) end + + def feature_key + params.expect(:feature_key) + end end diff --git a/config/routes.rb b/config/routes.rb index 78534ea58..1ce967e76 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -28,6 +28,8 @@ end post '/test/reseed', to: 'test_utilities#reseed' + post '/test/enable_feature', to: 'test_utilities#enable_feature' + post '/test/disable_feature', to: 'test_utilities#disable_feature' post '/graphql', to: 'graphql#execute' mount GraphiQL::Rails::Engine, at: '/graphql', graphql_path: '/graphql#execute' unless Rails.env.production? diff --git a/spec/requests/test_utilities_controller_spec.rb b/spec/requests/test_utilities_controller_spec.rb new file mode 100644 index 000000000..d525a8716 --- /dev/null +++ b/spec/requests/test_utilities_controller_spec.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TestUtilitiesController do + let(:headers) { { 'X-RESEED-API-KEY' => ENV.fetch('RESEED_API_KEY', nil) } } + + before do + host! 'test-editor-api.raspberrypi.org' + ENV['RESEED_API_KEY'] = 'my_test_api_key' + end + + after do + ENV.delete('RESEED_API_KEY') + end + + describe 'POST /test/reseed' do + subject(:request) { post('/test/reseed', headers:) } + + before do + allow(Rake::Task['test_seeds:destroy']).to receive(:execute) + allow(Rake::Task['test_seeds:create']).to receive(:execute) + end + + around do |example| + DatabaseCleaner.clean_with(:truncation) + DatabaseCleaner.strategy = :transaction + + DatabaseCleaner.cleaning do + Rake::Task.tasks.each(&:reenable) + example.run + ensure + Rake::Task.tasks.each(&:reenable) + end + end + + it 'returns OK' do + request + expect(response).to be_ok + end + + it 'destroys the test seeds' do + request + expect(Rake::Task['test_seeds:destroy']).to have_received(:execute) + end + + it 'recreates the test seeds' do + request + expect(Rake::Task['test_seeds:create']).to have_received(:execute) + end + + context 'when the host is not allowed' do + before do + host! 'editor-api.raspberrypi.org' + end + + it 'returns not found' do + request + expect(response).to be_not_found + end + + it 'does not destroy test seeds' do + request + expect(Rake::Task['test_seeds:destroy']).not_to have_received(:execute) + end + + it 'does not recreate test seeds' do + request + expect(Rake::Task['test_seeds:create']).not_to have_received(:execute) + end + end + + context 'when the RESEED_API_KEY is not set in the environment' do + let(:headers) { { 'X-RESEED-API-KEY' => '' } } + + before do + ENV.delete('RESEED_API_KEY') + end + + it 'returns not found' do + request + expect(response).to be_not_found + end + + it 'does not destroy test seeds' do + request + expect(Rake::Task['test_seeds:destroy']).not_to have_received(:execute) + end + + it 'does not recreate test seeds' do + request + expect(Rake::Task['test_seeds:create']).not_to have_received(:execute) + end + end + + context 'when the X-RESEED_API_KEY is incorrect' do + let(:headers) { { 'X-RESEED-API-KEY' => 'my_dodgy_api_key' } } + + it 'returns not found' do + request + expect(response).to be_not_found + end + + it 'does not destroy test seeds' do + request + expect(Rake::Task['test_seeds:destroy']).not_to have_received(:execute) + end + + it 'does not recreate test seeds' do + request + expect(Rake::Task['test_seeds:create']).not_to have_received(:execute) + end + end + end + + describe 'feature toggle endpoints' do + let(:school) { create(:school) } + let(:feature_key) { :flipper_feature } + let(:params) do + { + feature_key: + } + end + + shared_examples 'a rejected feature toggle request' do + it 'returns not found' do + request + expect(response).to be_not_found + end + + it 'does not change the feature' do + expect { request }.not_to(change { Flipper.enabled?(feature_key, school) }) + end + end + + shared_examples 'a feature toggle endpoint' do + it 'returns no content' do + request + expect(response).to be_no_content + end + + context 'when the feature_key param is missing' do + let(:params) { {} } + + it 'returns bad request' do + request + expect(response).to be_bad_request + end + + it 'does not change the feature' do + expect { request }.not_to(change { Flipper.enabled?(feature_key, school) }) + end + end + + context 'when the host is not allowed' do + before { host! 'editor-api.raspberrypi.org' } + + it_behaves_like 'a rejected feature toggle request' + end + + context 'when the RESEED_API_KEY is not set in the environment' do + let(:headers) { { 'X-RESEED-API-KEY' => '' } } + + before { ENV.delete('RESEED_API_KEY') } + + it_behaves_like 'a rejected feature toggle request' + end + + context 'when the X-RESEED-API-KEY is incorrect' do + let(:headers) { { 'X-RESEED-API-KEY' => 'my_dodgy_api_key' } } + + it_behaves_like 'a rejected feature toggle request' + end + end + + describe 'POST /test/enable_feature' do + subject(:request) { post('/test/enable_feature', headers:, params:) } + + before do + Flipper.disable(feature_key) + end + + it 'universally enables the feature' do + expect { request }.to change { Flipper.enabled?(feature_key, school) }.from(false).to(true) + end + + it_behaves_like 'a feature toggle endpoint' + end + + describe 'POST /test/disable_feature' do + subject(:request) { post('/test/disable_feature', headers:, params:) } + + before do + Flipper.enable_actor(feature_key, school) + end + + it 'universally disables the feature' do + expect { request }.to change { Flipper.enabled?(feature_key, school) }.from(true).to(false) + end + + it_behaves_like 'a feature toggle endpoint' + end + end +end diff --git a/spec/requests/test_utilities_spec.rb b/spec/requests/test_utilities_spec.rb deleted file mode 100644 index c3e99276b..000000000 --- a/spec/requests/test_utilities_spec.rb +++ /dev/null @@ -1,111 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe 'POST /test/reseed' do - subject(:request) { post('/test/reseed', headers:) } - - let(:headers) { { 'X-RESEED-API-KEY' => ENV.fetch('RESEED_API_KEY', nil) } } - - before do - allow(Rake::Task['test_seeds:destroy']).to receive(:execute) - allow(Rake::Task['test_seeds:create']).to receive(:execute) - - host! 'test-editor-api.raspberrypi.org' - ENV['RESEED_API_KEY'] = 'my_test_api_key' - end - - around do |example| - DatabaseCleaner.clean_with(:truncation) - DatabaseCleaner.strategy = :transaction - - DatabaseCleaner.cleaning do - Rake::Task.tasks.each(&:reenable) - example.run - ensure - Rake::Task.tasks.each(&:reenable) - end - end - - after do - ENV.delete('RESEED_API_KEY') - end - - it 'returns OK' do - request - expect(response).to be_ok - end - - it 'destroys the test seeds' do - request - expect(Rake::Task['test_seeds:destroy']).to have_received(:execute) - end - - it 'recreates the test seeds' do - request - expect(Rake::Task['test_seeds:create']).to have_received(:execute) - end - - context 'when the host is not allowed' do - before do - host! 'editor-api.raspberrypi.org' - end - - it 'returns not found' do - request - expect(response).to be_not_found - end - - it 'does not destroy test seeds' do - request - expect(Rake::Task['test_seeds:destroy']).not_to have_received(:execute) - end - - it 'does not recreate test seeds' do - request - expect(Rake::Task['test_seeds:create']).not_to have_received(:execute) - end - end - - context 'when the RESEED_API_KEY is not set in the environment' do - let(:headers) { { 'X-RESEED-API-KEY' => '' } } - - before do - ENV.delete('RESEED_API_KEY') - end - - it 'returns not found' do - request - expect(response).to be_not_found - end - - it 'does not destroy test seeds' do - request - expect(Rake::Task['test_seeds:destroy']).not_to have_received(:execute) - end - - it 'does not recreate test seeds' do - request - expect(Rake::Task['test_seeds:create']).not_to have_received(:execute) - end - end - - context 'when the X-RESEED_API_KEY is incorrect' do - let(:headers) { { 'X-RESEED-API-KEY' => 'my_dodgy_api_key' } } - - it 'returns not found' do - request - expect(response).to be_not_found - end - - it 'does not destroy test seeds' do - request - expect(Rake::Task['test_seeds:destroy']).not_to have_received(:execute) - end - - it 'does not recreate test seeds' do - request - expect(Rake::Task['test_seeds:create']).not_to have_received(:execute) - end - end -end From dd2263bfa6bb4bbbd475e6400547768df46d9fbf Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:09:26 +0100 Subject: [PATCH 2/2] Remove hard-coded feature flags in test seeds --- lib/tasks/test_seeds.rake | 5 +++-- spec/lib/test_seeds_spec.rb | 16 ++++++---------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/lib/tasks/test_seeds.rake b/lib/tasks/test_seeds.rake index 264963e96..9a7bbcc2b 100644 --- a/lib/tasks/test_seeds.rake +++ b/lib/tasks/test_seeds.rake @@ -37,6 +37,9 @@ namespace :test_seeds do school_ids = [school_id, teacher_signup_school_id].compact School.where(id: school_ids).destroy_all + # Clear any feature flags that may have been set + Flipper.features.each(&:remove) + Rails.logger.info 'Done...' end end @@ -52,8 +55,6 @@ namespace :test_seeds do teacher_id = ENV.fetch('SEEDING_TEACHER_ID', TEST_USERS[:john_doe]) school = create_school(creator_id, TEST_SCHOOL) - Flipper.enable_actor :cat_mode, school - Flipper.enable_actor :student_sso, school verify_school(school) assign_a_teacher(teacher_id, school) diff --git a/spec/lib/test_seeds_spec.rb b/spec/lib/test_seeds_spec.rb index ae5457478..8c88d54d1 100644 --- a/spec/lib/test_seeds_spec.rb +++ b/spec/lib/test_seeds_spec.rb @@ -38,6 +38,12 @@ expect(Project.where(school_id: school.id)).not_to exist expect(ScratchAsset.where(project_id: scratch_project_id)).not_to exist end + + it 'removes all feature flags' do + Flipper.enable(:some_feature) + task.invoke + expect(Flipper.features).to be_empty + end end describe ':seed_a_school_with_lessons_and_students' do @@ -54,16 +60,6 @@ expect(School.find_by(creator_id:).verified_at).to be_truthy end - it 'enables scratch for the school' do - school = School.find_by(creator_id:) - expect(Flipper.enabled?(:cat_mode, school)).to be(true) - end - - it 'enables student sso for the school' do - school = School.find_by(creator_id:) - expect(Flipper.enabled?(:student_sso, school)).to be(true) - end - it 'creates lessons with projects' do school = School.find_by(creator_id:) expect(SchoolClass.where(school_id: school.id)).to exist