From b9815a1d0d74263025ce418b2bf887890d05e052 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Wed, 22 Apr 2026 13:42:09 +0100 Subject: [PATCH 01/14] Add JoinCodeGenerator utility for generating unique join codes --- lib/join_code_generator.rb | 23 ++++++++++++++++++ spec/lib/join_code_generator_spec.rb | 35 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 lib/join_code_generator.rb create mode 100644 spec/lib/join_code_generator_spec.rb diff --git a/lib/join_code_generator.rb b/lib/join_code_generator.rb new file mode 100644 index 000000000..6fc57b9ec --- /dev/null +++ b/lib/join_code_generator.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class JoinCodeGenerator + CONSONANTS = %w[B C D F G H J K L M N P Q R S T V W X Y Z].freeze + VOWELS = %w[A E I O U].freeze + + cattr_accessor :random + + self.random ||= Random.new + + def self.generate + code = [ + CONSONANTS.sample(random: random), + VOWELS.sample(random: random), + CONSONANTS.sample(random: random), + VOWELS.sample(random: random) + ].join + + digits = format('%04d', random.rand(10_000)) + + "#{code}#{digits}" + end +end diff --git a/spec/lib/join_code_generator_spec.rb b/spec/lib/join_code_generator_spec.rb new file mode 100644 index 000000000..bb860a38b --- /dev/null +++ b/spec/lib/join_code_generator_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe JoinCodeGenerator do + describe '.generate' do + it 'generates a string in CVCVDDDD format' do + expect(described_class.generate).to match(/\A[A-Z]{4}\d{4}\z/) + end + + it 'generates consonant-vowel-consonant-vowel pattern' do + code = described_class.generate + letters = code[0..3] + + consonants = JoinCodeGenerator::CONSONANTS + vowels = JoinCodeGenerator::VOWELS + + expect(consonants).to include(letters[0]) + expect(vowels).to include(letters[1]) + expect(consonants).to include(letters[2]) + expect(vowels).to include(letters[3]) + end + + it 'generates a different code each time' do + codes = Array.new(100) { described_class.generate } + expect(codes.uniq.length).to be > 90 + end + + it 'generates 4 digits at the end' do + code = described_class.generate + digits = code[4..7] + expect(digits).to match(/\A\d{4}\z/) + end + end +end From deb1f7344226b3d5b68bb403e026ac2c4fe87066 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Wed, 22 Apr 2026 13:42:13 +0100 Subject: [PATCH 02/14] Add join_code column to school_classes with backfill migration --- ...20260416124302_add_join_code_to_school_classes.rb | 6 ++++++ ...16124324_backfill_join_code_for_school_classes.rb | 12 ++++++++++++ db/schema.rb | 4 +++- 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260416124302_add_join_code_to_school_classes.rb create mode 100644 db/migrate/20260416124324_backfill_join_code_for_school_classes.rb diff --git a/db/migrate/20260416124302_add_join_code_to_school_classes.rb b/db/migrate/20260416124302_add_join_code_to_school_classes.rb new file mode 100644 index 000000000..8cf99c09f --- /dev/null +++ b/db/migrate/20260416124302_add_join_code_to_school_classes.rb @@ -0,0 +1,6 @@ +class AddJoinCodeToSchoolClasses < ActiveRecord::Migration[7.2] + def change + add_column :school_classes, :join_code, :string + add_index :school_classes, :join_code, unique: true + end +end diff --git a/db/migrate/20260416124324_backfill_join_code_for_school_classes.rb b/db/migrate/20260416124324_backfill_join_code_for_school_classes.rb new file mode 100644 index 000000000..0d67204a7 --- /dev/null +++ b/db/migrate/20260416124324_backfill_join_code_for_school_classes.rb @@ -0,0 +1,12 @@ +class BackfillJoinCodeForSchoolClasses < ActiveRecord::Migration[7.2] + def up + SchoolClass.find_each do |school_class| + school_class.assign_join_code + school_class.save!(validate: false) + end + end + + def down + # No need to revert - join codes can stay + end +end diff --git a/db/schema.rb b/db/schema.rb index eb22dba99..fd00d8caa 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_04_10_110000) do +ActiveRecord::Schema[7.2].define(version: 2026_04_16_124324) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -264,7 +264,9 @@ t.string "code" t.integer "import_origin" t.string "import_id" + t.string "join_code" t.index ["code", "school_id"], name: "index_school_classes_on_code_and_school_id", unique: true + t.index ["join_code"], name: "index_school_classes_on_join_code", unique: true t.index ["school_id"], name: "index_school_classes_on_school_id" end From 65ce7479d98624d75d0e3e25aa35216a0f971940 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Wed, 22 Apr 2026 13:42:18 +0100 Subject: [PATCH 03/14] Implement join code generation and regeneration in SchoolClass model --- app/models/school_class.rb | 21 +++++++++ spec/factories/school_class.rb | 1 + spec/models/school_class_spec.rb | 76 ++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) diff --git a/app/models/school_class.rb b/app/models/school_class.rb index e35f9b4f6..a051465ba 100644 --- a/app/models/school_class.rb +++ b/app/models/school_class.rb @@ -10,9 +10,11 @@ class SchoolClass < ApplicationRecord scope :with_teachers, ->(user_id) { joins(:teachers).where(teachers: { id: user_id }) } before_validation :assign_class_code, on: %i[create import] + before_validation :assign_join_code, on: %i[create import] validates :name, presence: true validates :code, uniqueness: { scope: :school_id }, presence: true, format: { with: /\d\d-\d\d-\d\d/, allow_nil: false } + validates :join_code, uniqueness: true, presence: true, format: { with: /\A[A-Z]{4}\d{4}\z/, allow_nil: false } validate :code_cannot_be_changed validate :school_class_has_at_least_one_teacher @@ -58,6 +60,21 @@ def assign_class_code errors.add(:code, 'could not be generated') end + def assign_join_code + return if join_code.present? + + loop do + self.join_code = JoinCodeGenerator.generate + break if join_code_is_unique? + end + end + + def regenerate_join_code! + self.join_code = nil + assign_join_code + save! + end + def submitted_count return 0 if lessons.empty? @@ -88,4 +105,8 @@ def code_cannot_be_changed def code_is_unique_within_school? code.present? && SchoolClass.where(code:, school:).none? end + + def join_code_is_unique? + join_code.present? && SchoolClass.where(join_code:).where.not(id:).none? + end end diff --git a/spec/factories/school_class.rb b/spec/factories/school_class.rb index 9dfcad3c1..4c27ed0c1 100644 --- a/spec/factories/school_class.rb +++ b/spec/factories/school_class.rb @@ -4,6 +4,7 @@ factory :school_class do sequence(:name) { |n| "Class #{n}" } code { ForEducationCodeGenerator.generate } + join_code { JoinCodeGenerator.generate } transient do teacher_ids { [SecureRandom.uuid] } diff --git a/spec/models/school_class_spec.rb b/spec/models/school_class_spec.rb index d90034d28..6767d3d45 100644 --- a/spec/models/school_class_spec.rb +++ b/spec/models/school_class_spec.rb @@ -262,6 +262,82 @@ end end + describe '#assign_join_code' do + it 'assigns a join code if not already present' do + school_class = build(:school_class, join_code: nil, school:) + school_class.assign_join_code + expect(school_class.join_code).to match(/\A[A-Z]{4}\d{4}\z/) + end + + it 'does not assign a join code if already present' do + school_class = build(:school_class, join_code: 'BAFA1234', school:) + school_class.assign_join_code + expect(school_class.join_code).to eq('BAFA1234') + end + + it 'retries until a unique join code is found' do + create(:school_class, join_code: 'BAFA1234', school:) + allow(JoinCodeGenerator).to receive(:generate).and_return('BAFA1234', 'BAFA1234', 'CAFE5678') + + new_class = build(:school_class, join_code: nil, school:) + new_class.assign_join_code + + expect(new_class.join_code).to eq('CAFE5678') + expect(JoinCodeGenerator).to have_received(:generate).exactly(3).times + end + end + + describe '#regenerate_join_code!' do + it 'generates a new join code' do + school_class = create(:school_class, teacher_ids: [teacher.id], school:) + old_join_code = school_class.join_code + school_class.regenerate_join_code! + expect(school_class.join_code).not_to eq(old_join_code) + expect(school_class.join_code).to match(/\A[A-Z]{4}\d{4}\z/) + end + + it 'persists the new join code' do + school_class = create(:school_class, teacher_ids: [teacher.id], school:) + school_class.regenerate_join_code! + expect(school_class.reload.join_code).to match(/\A[A-Z]{4}\d{4}\z/) + end + end + + describe 'join_code validations' do + subject(:school_class) { build(:school_class, teacher_ids: [teacher.id, second_teacher.id], school:) } + + it 'assigns join code before validating' do + school_class.join_code = nil + school_class.valid? + expect(school_class.join_code).to match(/\A[A-Z]{4}\d{4}\z/) + end + + it 'requires a globally unique join code' do + school_class.save! + other_school = create(:school) + school_class_with_duplicate = build(:school_class, school: other_school, join_code: school_class.join_code) + school_class_with_duplicate.valid? + expect(school_class_with_duplicate.errors[:join_code]).to include('has already been taken') + end + + it 'requires a valid join code format' do + school_class.join_code = 'invalid' + expect(school_class).not_to be_valid + end + + it 'accepts a valid join code format' do + school_class.join_code = 'BAFA1234' + expect(school_class).to be_valid + end + + it 'allows the join code to be changed' do + school_class.join_code = 'BAFA1234' + school_class.save! + school_class.join_code = 'CAFE5678' + expect(school_class).to be_valid + end + end + describe '#submitted_count' do it 'returns 0 if there are no lessons' do school_class = create(:school_class, teacher_ids: [teacher.id], school:) From 2a91389ade1ded1741b85441cee77acb3f5e7e63 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Wed, 22 Apr 2026 13:42:22 +0100 Subject: [PATCH 04/14] Add regenerate_join_code API endpoint and expose join_code in responses --- .../api/school_classes_controller.rb | 8 ++ .../_school_class.json.jbuilder | 3 +- .../regenerating_join_code_spec.rb | 84 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 spec/features/school_class/regenerating_join_code_spec.rb diff --git a/app/controllers/api/school_classes_controller.rb b/app/controllers/api/school_classes_controller.rb index 7774044be..17d9baae7 100644 --- a/app/controllers/api/school_classes_controller.rb +++ b/app/controllers/api/school_classes_controller.rb @@ -81,6 +81,14 @@ def destroy end end + def regenerate_join_code + @school_class.regenerate_join_code! + @school_class_with_teachers = @school_class.with_teachers + render :show, formats: [:json], status: :ok + rescue ActiveRecord::RecordInvalid => e + render json: { error: e.message }, status: :unprocessable_content + end + private def render_student_index(school_classes) diff --git a/app/views/api/school_classes/_school_class.json.jbuilder b/app/views/api/school_classes/_school_class.json.jbuilder index 135ae733f..93f03f44c 100644 --- a/app/views/api/school_classes/_school_class.json.jbuilder +++ b/app/views/api/school_classes/_school_class.json.jbuilder @@ -9,7 +9,8 @@ json.call( :created_at, :updated_at, :import_origin, - :import_id + :import_id, + :join_code ) json.teachers(teachers) do |teacher| diff --git a/spec/features/school_class/regenerating_join_code_spec.rb b/spec/features/school_class/regenerating_join_code_spec.rb new file mode 100644 index 000000000..a378a8498 --- /dev/null +++ b/spec/features/school_class/regenerating_join_code_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Regenerating a school class join code', type: :request do + before do + authenticated_in_hydra_as(teacher) + end + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:teacher) { create(:teacher, school:, name: 'School Teacher') } + let!(:school_class) { create(:school_class, name: 'Test School Class', school:, teacher_ids: [teacher.id]) } + + it 'responds 200 OK' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/regenerate_join_code", headers:) + expect(response).to have_http_status(:ok) + end + + it 'responds 200 OK when the user is the school-teacher for the class' do + authenticated_in_hydra_as(teacher) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/regenerate_join_code", headers:) + expect(response).to have_http_status(:ok) + end + + it 'generates a new join code' do + old_join_code = school_class.join_code + + post("/api/schools/#{school.id}/classes/#{school_class.id}/regenerate_join_code", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:join_code]).not_to eq(old_join_code) + expect(data[:join_code]).to match(/\A[A-Z]{4}\d{4}\z/) + end + + it 'responds with the updated school class JSON including the new join code' do + old_join_code = school_class.join_code + + post("/api/schools/#{school.id}/classes/#{school_class.id}/regenerate_join_code", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:id]).to eq(school_class.id) + expect(data[:name]).to eq('Test School Class') + expect(data[:join_code]).to be_present + expect(data[:join_code]).not_to eq(old_join_code) + end + + it 'responds with the teacher JSON' do + post("/api/schools/#{school.id}/classes/#{school_class.id}/regenerate_join_code", headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:teachers].first[:name]).to eq('School Teacher') + end + + it 'responds 401 Unauthorized when no token is given' do + post "/api/schools/#{school.id}/classes/#{school_class.id}/regenerate_join_code" + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-owner for a different school' do + other_school = create(:school) + other_class = create(:school_class, school: other_school) + + post("/api/schools/#{other_school.id}/classes/#{other_class.id}/regenerate_join_code", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is not the school-teacher for the class' do + other_teacher = create(:teacher, school:) + authenticated_in_hydra_as(other_teacher) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/regenerate_join_code", headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + post("/api/schools/#{school.id}/classes/#{school_class.id}/regenerate_join_code", headers:) + expect(response).to have_http_status(:forbidden) + end +end From ceb90785535385bc841d9d9b9c24c4c2fb65b7fb Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Wed, 22 Apr 2026 13:42:26 +0100 Subject: [PATCH 05/14] Add join endpoint for class enrollment via join codes --- app/controllers/join_controller.rb | 64 ++++++++++++++ app/models/school.rb | 6 ++ config/routes.rb | 3 + spec/requests/join_controller_spec.rb | 119 ++++++++++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 app/controllers/join_controller.rb create mode 100644 spec/requests/join_controller_spec.rb diff --git a/app/controllers/join_controller.rb b/app/controllers/join_controller.rb new file mode 100644 index 000000000..2f654d58e --- /dev/null +++ b/app/controllers/join_controller.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +class JoinController < ApplicationController + include Identifiable + + rescue_from ActiveRecord::RecordNotFound, with: :not_found + + before_action :find_school_and_class, only: [:show] + + def show + unless current_user + redirect_to_profile_with_school_code_and_join_code + return + end + + if user_in_different_school? + render json: { error: 'You are already a member of a different school' }, status: :forbidden + return + end + + unless @school.valid_email?(current_user.email) + render json: { error: 'Your email domain does not match the school' }, status: :forbidden + return + end + + add_user_to_school_and_class + + redirect_to "/schools/#{@school.code}/classes/#{@school_class.code}" + end + + private + + def find_school_and_class + @normalized_join_code = params[:join_code].to_s.upcase.gsub(/[^A-Z0-9]/, '') + @school_class = SchoolClass.find_by!(join_code: @normalized_join_code) + @school = @school_class.school + end + + def redirect_to_profile_with_school_code_and_join_code + profile_url = "#{ENV.fetch('PROFILE_URL')}/student-login" + join_url = "#{request.base_url}/join/#{@normalized_join_code}" + login_options = "school_code=#{@school.code}" + redirect_to "#{profile_url}?loginOptions=#{CGI.escape(login_options)}&redirect_url=#{CGI.escape(join_url)}", allow_other_host: true + end + + def user_in_different_school? + existing_school = Role.find_by(user_id: current_user.id)&.school + existing_school.present? && existing_school.id != @school.id + end + + def add_user_to_school_and_class + # Add to school if not already a member + Role.create!(school: @school, user_id: current_user.id, role: :student) unless Role.exists?(school: @school, user_id: current_user.id) + + # Add to class if not already a member + return if ClassStudent.exists?(school_class: @school_class, student_id: current_user.id) + + ClassStudent.create!(school_class: @school_class, student_id: current_user.id) + end + + def not_found + head :not_found + end +end diff --git a/app/models/school.rb b/app/models/school.rb index fe35f00fa..bd5e4538b 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -116,6 +116,12 @@ def import_in_progress? .exists?(description: id) end + def valid_email?(_email) + # TODO: Implement actual domain verification + # This stub always returns true for now + true + end + private # Ensure the reference is nil, not an empty string diff --git a/config/routes.rb b/config/routes.rb index 24aaca4bf..f960cf2de 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -28,6 +28,8 @@ post '/test/reseed', to: 'test_utilities#reseed' + get '/join/:join_code', to: 'join#show', as: 'join' + post '/graphql', to: 'graphql#execute' mount GraphiQL::Rails::Engine, at: '/graphql', graphql_path: '/graphql#execute' unless Rails.env.production? @@ -70,6 +72,7 @@ resources :members, only: %i[index], controller: 'school_members' resources :classes, only: %i[index show create update destroy], controller: 'school_classes' do post :import, on: :collection + post :regenerate_join_code, on: :member resources :members, only: %i[index create destroy], controller: 'class_members' do post :batch, on: :collection, to: 'class_members#create_batch' end diff --git a/spec/requests/join_controller_spec.rb b/spec/requests/join_controller_spec.rb new file mode 100644 index 000000000..a43e3baef --- /dev/null +++ b/spec/requests/join_controller_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Join endpoint' do + let(:school) { create(:school, code: '12-34-56') } + let(:school_class) { create(:school_class, school:, join_code: 'BAFA2345') } + let(:student) { create(:user) } # Don't create role yet - let join endpoint do it + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + + before do + stub_const('ENV', ENV.to_hash.merge('PROFILE_URL' => 'https://profile.example.com')) + end + + describe 'GET /join/:join_code' do + context 'when user is not logged in' do + it 'redirects to Profile student-login with school code and return URL' do + get "/join/#{school_class.join_code}" + + expect(response).to have_http_status(:redirect) + redirect_url = response.location + expect(redirect_url).to include("#{ENV.fetch('PROFILE_URL')}/student-login") + expect(redirect_url).to include('loginOptions=school_code') + expect(redirect_url).to include(school.code.to_s) + expect(redirect_url).to include('redirect_url=') + expect(redirect_url).to include('join') + expect(redirect_url).to include(school_class.join_code) + end + + it 'normalizes join code by removing non-alphanumeric characters' do + # Create a class with the normalized code + school_class.update!(join_code: 'BAFA2345') + + get '/join/BAFA-2345' + + expect(response).to have_http_status(:redirect) + redirect_url = response.location + expect(redirect_url).to include('BAFA2345') + expect(redirect_url).not_to include('BAFA-2345') + expect(redirect_url).to include(school.code.to_s) + end + + it 'responds with 404 when join code does not exist' do + get '/join/INVALID123' + expect(response).to have_http_status(:not_found) + end + end + + context 'when user is logged in' do + before do + authenticated_in_hydra_as(student) + end + + it 'adds the user to the school and class and redirects to class page' do + expect do + get "/join/#{school_class.join_code}", headers: + end.to change(ClassStudent, :count).by(1).and change(Role, :count).by(1) + + expect(response).to have_http_status(:redirect) + expect(response.location).to include("/schools/#{school.code}/classes/#{school_class.code}") + + created_role = Role.find_by(user_id: student.id, school:) + expect(created_role).to be_present + expect(created_role.role).to eq('student') + end + + it 'does not create duplicate school role if user already in school' do + create(:student_role, school:, user_id: student.id) + + expect do + get "/join/#{school_class.join_code}", headers: + end.not_to change(Role, :count) + end + + it 'does not create duplicate class membership if user already in class' do + create(:student_role, school:, user_id: student.id) + ClassStudent.create!(school_class:, student_id: student.id) + + expect do + get "/join/#{school_class.join_code}", headers: + end.not_to change(ClassStudent, :count) + + expect(response).to have_http_status(:redirect) + end + + context 'when user email domain does not match school' do + before do + allow(school_class).to receive(:school).and_return(school) + allow(school).to receive(:valid_email?).and_return(false) + allow(SchoolClass).to receive(:find_by!).and_return(school_class) + end + + it 'responds with 403 Forbidden' do + get "/join/#{school_class.join_code}", headers: headers + + expect(response).to have_http_status(:forbidden) + data = JSON.parse(response.body, symbolize_names: true) + expect(data[:error]).to eq('Your email domain does not match the school') + end + end + + context 'when user is already a member of a different school' do + let(:other_school) { create(:school) } + + before do + create(:student_role, school: other_school, user_id: student.id) + end + + it 'responds with 403 Forbidden' do + get "/join/#{school_class.join_code}", headers: headers + + expect(response).to have_http_status(:forbidden) + data = JSON.parse(response.body, symbolize_names: true) + expect(data[:error]).to eq('You are already a member of a different school') + end + end + end + end +end From bb23e12e6ce259b820f9f71e7be23d19b6ec2223 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Wed, 22 Apr 2026 13:42:31 +0100 Subject: [PATCH 06/14] Add CanCan authorization rules for join code functionality --- app/models/ability.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/ability.rb b/app/models/ability.rb index 011460a18..8365cf7a3 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -62,7 +62,7 @@ def define_authenticated_non_student_abilities(user) def define_school_owner_abilities(school:) can(%i[read update destroy], School, id: school.id) can(%i[read], :school_member) - can(%i[read create import update destroy], SchoolClass, school: { id: school.id }) + can(%i[read create import update destroy regenerate_join_code], SchoolClass, school: { id: school.id }) can(%i[read show_context], Project, school_id: school.id, lesson: { visibility: %w[teachers students] }) can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id } }) can(%i[read create destroy], :school_owner) @@ -78,7 +78,7 @@ def define_school_teacher_abilities(user:, school:) can(%i[read], School, id: school.id) can(%i[read], :school_member) can(%i[create import], SchoolClass, school: { id: school.id }) - can(%i[read update destroy], SchoolClass, school: { id: school.id }, teachers: { teacher_id: user.id }) + can(%i[read update destroy regenerate_join_code], SchoolClass, school: { id: school.id }, teachers: { teacher_id: user.id }) can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id }, teachers: { teacher_id: user.id } }) can(%i[read], :school_owner) can(%i[read], :school_teacher) From 29c9288e10528733c99d1f3ec758593462be3e1d Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Thu, 23 Apr 2026 10:47:25 +0100 Subject: [PATCH 07/14] Fix: Use IDENTITY_URL instead of undefined PROFILE_URL --- app/controllers/join_controller.rb | 2 +- spec/requests/join_controller_spec.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/join_controller.rb b/app/controllers/join_controller.rb index 2f654d58e..2493a73be 100644 --- a/app/controllers/join_controller.rb +++ b/app/controllers/join_controller.rb @@ -37,7 +37,7 @@ def find_school_and_class end def redirect_to_profile_with_school_code_and_join_code - profile_url = "#{ENV.fetch('PROFILE_URL')}/student-login" + profile_url = "#{ENV.fetch('IDENTITY_URL')}/student-login" join_url = "#{request.base_url}/join/#{@normalized_join_code}" login_options = "school_code=#{@school.code}" redirect_to "#{profile_url}?loginOptions=#{CGI.escape(login_options)}&redirect_url=#{CGI.escape(join_url)}", allow_other_host: true diff --git a/spec/requests/join_controller_spec.rb b/spec/requests/join_controller_spec.rb index a43e3baef..d91992ed7 100644 --- a/spec/requests/join_controller_spec.rb +++ b/spec/requests/join_controller_spec.rb @@ -9,7 +9,7 @@ let(:headers) { { Authorization: UserProfileMock::TOKEN } } before do - stub_const('ENV', ENV.to_hash.merge('PROFILE_URL' => 'https://profile.example.com')) + stub_const('ENV', ENV.to_hash.merge('IDENTITY_URL' => 'https://profile.example.com')) end describe 'GET /join/:join_code' do @@ -19,7 +19,7 @@ expect(response).to have_http_status(:redirect) redirect_url = response.location - expect(redirect_url).to include("#{ENV.fetch('PROFILE_URL')}/student-login") + expect(redirect_url).to include("#{ENV.fetch('IDENTITY_URL')}/student-login") expect(redirect_url).to include('loginOptions=school_code') expect(redirect_url).to include(school.code.to_s) expect(redirect_url).to include('redirect_url=') From c48e4938d0f9819132e702878c49235c55341781 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Thu, 23 Apr 2026 10:52:34 +0100 Subject: [PATCH 08/14] Add PROFILE_URL for external browser redirects to Profile --- .env.example | 1 + app/controllers/join_controller.rb | 2 +- spec/requests/join_controller_spec.rb | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index bcf9830dc..d1df03173 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,7 @@ HYDRA_CLIENT_ID=editor-dashboard-dev HYDRA_CLIENT_SECRET=secret IDENTITY_URL=http://host.docker.internal:3002 # Internal docker address +PROFILE_URL=http://localhost:3002 # External address for browser redirects SMEE_TUNNEL=https://smee.io/MLq0n9kvAes2vydX diff --git a/app/controllers/join_controller.rb b/app/controllers/join_controller.rb index 2493a73be..75be3997a 100644 --- a/app/controllers/join_controller.rb +++ b/app/controllers/join_controller.rb @@ -37,7 +37,7 @@ def find_school_and_class end def redirect_to_profile_with_school_code_and_join_code - profile_url = "#{ENV.fetch('IDENTITY_URL')}/student-login" + profile_url = "#{ENV.fetch('PROFILE_URL', ENV.fetch('IDENTITY_URL'))}/student-login" join_url = "#{request.base_url}/join/#{@normalized_join_code}" login_options = "school_code=#{@school.code}" redirect_to "#{profile_url}?loginOptions=#{CGI.escape(login_options)}&redirect_url=#{CGI.escape(join_url)}", allow_other_host: true diff --git a/spec/requests/join_controller_spec.rb b/spec/requests/join_controller_spec.rb index d91992ed7..a43e3baef 100644 --- a/spec/requests/join_controller_spec.rb +++ b/spec/requests/join_controller_spec.rb @@ -9,7 +9,7 @@ let(:headers) { { Authorization: UserProfileMock::TOKEN } } before do - stub_const('ENV', ENV.to_hash.merge('IDENTITY_URL' => 'https://profile.example.com')) + stub_const('ENV', ENV.to_hash.merge('PROFILE_URL' => 'https://profile.example.com')) end describe 'GET /join/:join_code' do @@ -19,7 +19,7 @@ expect(response).to have_http_status(:redirect) redirect_url = response.location - expect(redirect_url).to include("#{ENV.fetch('IDENTITY_URL')}/student-login") + expect(redirect_url).to include("#{ENV.fetch('PROFILE_URL')}/student-login") expect(redirect_url).to include('loginOptions=school_code') expect(redirect_url).to include(school.code.to_s) expect(redirect_url).to include('redirect_url=') From 40ed90be64cf638f82d7d697d0b7abed28ac6104 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Thu, 23 Apr 2026 11:01:45 +0100 Subject: [PATCH 09/14] Redirect unauthenticated users to editor school page instead of Profile --- app/controllers/join_controller.rb | 5 ++--- spec/requests/join_controller_spec.rb | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/controllers/join_controller.rb b/app/controllers/join_controller.rb index 75be3997a..39927d769 100644 --- a/app/controllers/join_controller.rb +++ b/app/controllers/join_controller.rb @@ -37,10 +37,9 @@ def find_school_and_class end def redirect_to_profile_with_school_code_and_join_code - profile_url = "#{ENV.fetch('PROFILE_URL', ENV.fetch('IDENTITY_URL'))}/student-login" + school_page_url = "#{ENV.fetch('EDITOR_PUBLIC_URL')}/en/school/#{@school.code}" join_url = "#{request.base_url}/join/#{@normalized_join_code}" - login_options = "school_code=#{@school.code}" - redirect_to "#{profile_url}?loginOptions=#{CGI.escape(login_options)}&redirect_url=#{CGI.escape(join_url)}", allow_other_host: true + redirect_to "#{school_page_url}?redirect_url=#{CGI.escape(join_url)}", allow_other_host: true end def user_in_different_school? diff --git a/spec/requests/join_controller_spec.rb b/spec/requests/join_controller_spec.rb index a43e3baef..80229ea9b 100644 --- a/spec/requests/join_controller_spec.rb +++ b/spec/requests/join_controller_spec.rb @@ -9,7 +9,7 @@ let(:headers) { { Authorization: UserProfileMock::TOKEN } } before do - stub_const('ENV', ENV.to_hash.merge('PROFILE_URL' => 'https://profile.example.com')) + stub_const('ENV', ENV.to_hash.merge('EDITOR_PUBLIC_URL' => 'https://editor.example.com')) end describe 'GET /join/:join_code' do @@ -19,8 +19,7 @@ expect(response).to have_http_status(:redirect) redirect_url = response.location - expect(redirect_url).to include("#{ENV.fetch('PROFILE_URL')}/student-login") - expect(redirect_url).to include('loginOptions=school_code') + expect(redirect_url).to include("#{ENV.fetch('EDITOR_PUBLIC_URL')}/en/school") expect(redirect_url).to include(school.code.to_s) expect(redirect_url).to include('redirect_url=') expect(redirect_url).to include('join') From 069f825e5c195bd6259939d948edae2ce202b415 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Thu, 23 Apr 2026 11:26:26 +0100 Subject: [PATCH 10/14] Short-circuit join flow if user is already a class member --- app/controllers/join_controller.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/controllers/join_controller.rb b/app/controllers/join_controller.rb index 39927d769..e64dcf6b3 100644 --- a/app/controllers/join_controller.rb +++ b/app/controllers/join_controller.rb @@ -13,6 +13,12 @@ def show return end + # If already a member of this class, just redirect + if ClassStudent.exists?(school_class: @school_class, student_id: current_user.id) + redirect_to "/schools/#{@school.code}/classes/#{@school_class.code}" + return + end + if user_in_different_school? render json: { error: 'You are already a member of a different school' }, status: :forbidden return From ce7cd0bf7262e76bb018e31cf812026424d412c7 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Thu, 23 Apr 2026 11:42:01 +0100 Subject: [PATCH 11/14] Redirect to editor-standalone class page URL instead of relative path --- app/controllers/join_controller.rb | 13 +++++++++---- spec/requests/join_controller_spec.rb | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/controllers/join_controller.rb b/app/controllers/join_controller.rb index e64dcf6b3..302993a97 100644 --- a/app/controllers/join_controller.rb +++ b/app/controllers/join_controller.rb @@ -9,13 +9,13 @@ class JoinController < ApplicationController def show unless current_user - redirect_to_profile_with_school_code_and_join_code + redirect_to_editor_school_page_with_join_url return end # If already a member of this class, just redirect if ClassStudent.exists?(school_class: @school_class, student_id: current_user.id) - redirect_to "/schools/#{@school.code}/classes/#{@school_class.code}" + redirect_to_class_page return end @@ -31,7 +31,7 @@ def show add_user_to_school_and_class - redirect_to "/schools/#{@school.code}/classes/#{@school_class.code}" + redirect_to_class_page end private @@ -42,12 +42,17 @@ def find_school_and_class @school = @school_class.school end - def redirect_to_profile_with_school_code_and_join_code + def redirect_to_editor_school_page_with_join_url school_page_url = "#{ENV.fetch('EDITOR_PUBLIC_URL')}/en/school/#{@school.code}" join_url = "#{request.base_url}/join/#{@normalized_join_code}" redirect_to "#{school_page_url}?redirect_url=#{CGI.escape(join_url)}", allow_other_host: true end + def redirect_to_class_page + class_page_url = "#{ENV.fetch('EDITOR_PUBLIC_URL')}/en/school/#{@school.code}/class/#{@school_class.code}" + redirect_to class_page_url, allow_other_host: true + end + def user_in_different_school? existing_school = Role.find_by(user_id: current_user.id)&.school existing_school.present? && existing_school.id != @school.id diff --git a/spec/requests/join_controller_spec.rb b/spec/requests/join_controller_spec.rb index 80229ea9b..3a4f7147e 100644 --- a/spec/requests/join_controller_spec.rb +++ b/spec/requests/join_controller_spec.rb @@ -56,7 +56,7 @@ end.to change(ClassStudent, :count).by(1).and change(Role, :count).by(1) expect(response).to have_http_status(:redirect) - expect(response.location).to include("/schools/#{school.code}/classes/#{school_class.code}") + expect(response.location).to include("#{ENV.fetch('EDITOR_PUBLIC_URL')}/en/school/#{school.code}/class/#{school_class.code}") created_role = Role.find_by(user_id: student.id, school:) expect(created_role).to be_present From 5f67804305c7c5f9557ac2321cff6998ef4c702d Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Thu, 23 Apr 2026 15:19:49 +0100 Subject: [PATCH 12/14] Convert join flow to API-first design - Move JoinController from root to Api namespace - Change from redirect-based to JSON API endpoints: - GET /api/join/:join_code - public endpoint returns school_code - POST /api/join/:join_code - authenticated endpoint enrolls user - Returns JSON with redirect_url and school_code - Remove all redirect logic from controller - Simpler, cleaner API design for frontend consumption --- app/controllers/api/join_controller.rb | 68 +++++++++++++++++++++++ app/controllers/join_controller.rb | 74 -------------------------- config/routes.rb | 3 ++ 3 files changed, 71 insertions(+), 74 deletions(-) create mode 100644 app/controllers/api/join_controller.rb delete mode 100644 app/controllers/join_controller.rb diff --git a/app/controllers/api/join_controller.rb b/app/controllers/api/join_controller.rb new file mode 100644 index 000000000..7c8a289f1 --- /dev/null +++ b/app/controllers/api/join_controller.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Api + class JoinController < ApiController + before_action :find_school_and_class + before_action :authorize_user, only: [:create] + + rescue_from ActiveRecord::RecordNotFound, with: :not_found + + def show + # Public endpoint to get school code for a join code (for login autofill) + render json: { school_code: @school.code }, status: :ok + end + + def create + # If already a member of this class, just return success + if ClassStudent.exists?(school_class: @school_class, student_id: current_user.id) + render json: { redirect_url: class_page_url, school_code: @school.code }, status: :ok + return + end + + if user_in_different_school? + render json: { error: 'You are already a member of a different school' }, status: :forbidden + return + end + + unless @school.valid_email?(current_user.email) + render json: { error: 'Your email domain does not match the school' }, status: :forbidden + return + end + + add_user_to_school_and_class + + render json: { redirect_url: class_page_url, school_code: @school.code }, status: :created + end + + private + + def find_school_and_class + @normalized_join_code = params[:join_code].to_s.upcase.gsub(/[^A-Z0-9]/, '') + @school_class = SchoolClass.find_by!(join_code: @normalized_join_code) + @school = @school_class.school + end + + def class_page_url + "/en/school/#{@school.code}/class/#{@school_class.code}" + end + + def user_in_different_school? + existing_school = Role.find_by(user_id: current_user.id)&.school + existing_school.present? && existing_school.id != @school.id + end + + def add_user_to_school_and_class + # Add to school if not already a member + Role.create!(school: @school, user_id: current_user.id, role: :student) unless Role.exists?(school: @school, user_id: current_user.id) + + # Add to class if not already a member + return if ClassStudent.exists?(school_class: @school_class, student_id: current_user.id) + + ClassStudent.create!(school_class: @school_class, student_id: current_user.id) + end + + def not_found + render json: { error: 'Join code not found' }, status: :not_found + end + end +end diff --git a/app/controllers/join_controller.rb b/app/controllers/join_controller.rb deleted file mode 100644 index 302993a97..000000000 --- a/app/controllers/join_controller.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -class JoinController < ApplicationController - include Identifiable - - rescue_from ActiveRecord::RecordNotFound, with: :not_found - - before_action :find_school_and_class, only: [:show] - - def show - unless current_user - redirect_to_editor_school_page_with_join_url - return - end - - # If already a member of this class, just redirect - if ClassStudent.exists?(school_class: @school_class, student_id: current_user.id) - redirect_to_class_page - return - end - - if user_in_different_school? - render json: { error: 'You are already a member of a different school' }, status: :forbidden - return - end - - unless @school.valid_email?(current_user.email) - render json: { error: 'Your email domain does not match the school' }, status: :forbidden - return - end - - add_user_to_school_and_class - - redirect_to_class_page - end - - private - - def find_school_and_class - @normalized_join_code = params[:join_code].to_s.upcase.gsub(/[^A-Z0-9]/, '') - @school_class = SchoolClass.find_by!(join_code: @normalized_join_code) - @school = @school_class.school - end - - def redirect_to_editor_school_page_with_join_url - school_page_url = "#{ENV.fetch('EDITOR_PUBLIC_URL')}/en/school/#{@school.code}" - join_url = "#{request.base_url}/join/#{@normalized_join_code}" - redirect_to "#{school_page_url}?redirect_url=#{CGI.escape(join_url)}", allow_other_host: true - end - - def redirect_to_class_page - class_page_url = "#{ENV.fetch('EDITOR_PUBLIC_URL')}/en/school/#{@school.code}/class/#{@school_class.code}" - redirect_to class_page_url, allow_other_host: true - end - - def user_in_different_school? - existing_school = Role.find_by(user_id: current_user.id)&.school - existing_school.present? && existing_school.id != @school.id - end - - def add_user_to_school_and_class - # Add to school if not already a member - Role.create!(school: @school, user_id: current_user.id, role: :student) unless Role.exists?(school: @school, user_id: current_user.id) - - # Add to class if not already a member - return if ClassStudent.exists?(school_class: @school_class, student_id: current_user.id) - - ClassStudent.create!(school_class: @school_class, student_id: current_user.id) - end - - def not_found - head :not_found - end -end diff --git a/config/routes.rb b/config/routes.rb index f960cf2de..ea60cd29e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -102,6 +102,9 @@ resources :features, only: %i[index] resources :profile_auth_check, only: %i[index] + + get '/join/:join_code', to: 'join#show' + post '/join/:join_code', to: 'join#create' end resource :github_webhooks, only: :create, defaults: { formats: :json } From 02cba8a04d96d403a7a58ca340996a447c983eae Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Thu, 23 Apr 2026 15:35:38 +0100 Subject: [PATCH 13/14] Change join code format to CVDDCVDD - Updated JoinCodeGenerator to produce format: Consonant-Vowel-2Digits-Consonant-Vowel-2Digits - Example: GE86SU10 instead of BAFA2345 - Updated validation regex in SchoolClass model - Updated specs to test new format - All tests passing --- app/models/school_class.rb | 2 +- lib/join_code_generator.rb | 8 +++---- spec/lib/join_code_generator_spec.rb | 31 +++++++++++++--------------- 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/app/models/school_class.rb b/app/models/school_class.rb index a051465ba..3deba6ff2 100644 --- a/app/models/school_class.rb +++ b/app/models/school_class.rb @@ -14,7 +14,7 @@ class SchoolClass < ApplicationRecord validates :name, presence: true validates :code, uniqueness: { scope: :school_id }, presence: true, format: { with: /\d\d-\d\d-\d\d/, allow_nil: false } - validates :join_code, uniqueness: true, presence: true, format: { with: /\A[A-Z]{4}\d{4}\z/, allow_nil: false } + validates :join_code, uniqueness: true, presence: true, format: { with: /\A[A-Z][A-Z0-9]{7}\z/, allow_nil: false } validate :code_cannot_be_changed validate :school_class_has_at_least_one_teacher diff --git a/lib/join_code_generator.rb b/lib/join_code_generator.rb index 6fc57b9ec..5e61e48e0 100644 --- a/lib/join_code_generator.rb +++ b/lib/join_code_generator.rb @@ -12,12 +12,12 @@ def self.generate code = [ CONSONANTS.sample(random: random), VOWELS.sample(random: random), + format('%02d', random.rand(100)), CONSONANTS.sample(random: random), - VOWELS.sample(random: random) + VOWELS.sample(random: random), + format('%02d', random.rand(100)) ].join - digits = format('%04d', random.rand(10_000)) - - "#{code}#{digits}" + code end end diff --git a/spec/lib/join_code_generator_spec.rb b/spec/lib/join_code_generator_spec.rb index bb860a38b..69a87e463 100644 --- a/spec/lib/join_code_generator_spec.rb +++ b/spec/lib/join_code_generator_spec.rb @@ -4,32 +4,29 @@ RSpec.describe JoinCodeGenerator do describe '.generate' do - it 'generates a string in CVCVDDDD format' do - expect(described_class.generate).to match(/\A[A-Z]{4}\d{4}\z/) + it 'generates a string in CVDDCVDD format' do + expect(described_class.generate).to match(/\A[A-Z][A-Z0-9]{7}\z/) end - it 'generates consonant-vowel-consonant-vowel pattern' do + it 'generates consonant-vowel-digit-digit-consonant-vowel-digit-digit pattern' do code = described_class.generate - letters = code[0..3] - consonants = JoinCodeGenerator::CONSONANTS vowels = JoinCodeGenerator::VOWELS - expect(consonants).to include(letters[0]) - expect(vowels).to include(letters[1]) - expect(consonants).to include(letters[2]) - expect(vowels).to include(letters[3]) + # Check pattern: C-V-DD-C-V-DD + expect(consonants).to include(code[0]) + expect(vowels).to include(code[1]) + expect(code[2]).to match(/\d/) + expect(code[3]).to match(/\d/) + expect(consonants).to include(code[4]) + expect(vowels).to include(code[5]) + expect(code[6]).to match(/\d/) + expect(code[7]).to match(/\d/) end it 'generates a different code each time' do - codes = Array.new(100) { described_class.generate } - expect(codes.uniq.length).to be > 90 - end - - it 'generates 4 digits at the end' do - code = described_class.generate - digits = code[4..7] - expect(digits).to match(/\A\d{4}\z/) + codes = 10.times.map { described_class.generate } + expect(codes.uniq.length).to eq(10) end end end From 617b5ff73bc940f2acbd96d1c48841c36e3da9d7 Mon Sep 17 00:00:00 2001 From: Fraser Speirs Date: Thu, 23 Apr 2026 15:39:19 +0100 Subject: [PATCH 14/14] Add offensive word filtering to join code generation - Remove consonants K, X, Z to reduce offensive word risk - Add OFFENSIVE_PATTERNS blacklist (AS, BA, BO, BU, DA, DI, FU, HO, PO, SH, TA, TI, VA) - Filter codes during generation to skip offensive CV patterns - Add test to verify no offensive patterns in generated codes - Max 100 attempts before raising error (very unlikely to hit) This prevents codes like BA55FU99 or DI12HO34 --- lib/join_code_generator.rb | 44 +++++++++++++++++++++------- spec/lib/join_code_generator_spec.rb | 13 ++++++++ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/lib/join_code_generator.rb b/lib/join_code_generator.rb index 5e61e48e0..8d6d7b315 100644 --- a/lib/join_code_generator.rb +++ b/lib/join_code_generator.rb @@ -1,23 +1,45 @@ # frozen_string_literal: true class JoinCodeGenerator - CONSONANTS = %w[B C D F G H J K L M N P Q R S T V W X Y Z].freeze + # Removed letters that commonly form offensive words: + # Removed vowels: None (need all 5 for readability) + # Removed consonants: K (dick, fuck), X (sex), Z (rarely used anyway) + CONSONANTS = %w[B C D F G H J L M N P Q R S T V W Y].freeze VOWELS = %w[A E I O U].freeze cattr_accessor :random self.random ||= Random.new + # List of offensive letter patterns to avoid (consonant-vowel pairs) + OFFENSIVE_PATTERNS = %w[ + AS BA BO BU DA DI FU HO PO SH TA TI VA + ].freeze + def self.generate - code = [ - CONSONANTS.sample(random: random), - VOWELS.sample(random: random), - format('%02d', random.rand(100)), - CONSONANTS.sample(random: random), - VOWELS.sample(random: random), - format('%02d', random.rand(100)) - ].join - - code + max_attempts = 100 + max_attempts.times do + code = [ + CONSONANTS.sample(random: random), + VOWELS.sample(random: random), + format('%02d', random.rand(100)), + CONSONANTS.sample(random: random), + VOWELS.sample(random: random), + format('%02d', random.rand(100)) + ].join + + # Extract the CV patterns (positions 0-1 and 4-5) + first_cv = code[0, 2] + second_cv = code[4, 2] + + # Check if either CV pair matches offensive patterns + next if OFFENSIVE_PATTERNS.include?(first_cv) + next if OFFENSIVE_PATTERNS.include?(second_cv) + + return code + end + + # Fallback if we couldn't generate a clean code after max_attempts + raise 'Unable to generate non-offensive join code' end end diff --git a/spec/lib/join_code_generator_spec.rb b/spec/lib/join_code_generator_spec.rb index 69a87e463..d4ea2115a 100644 --- a/spec/lib/join_code_generator_spec.rb +++ b/spec/lib/join_code_generator_spec.rb @@ -28,5 +28,18 @@ codes = 10.times.map { described_class.generate } expect(codes.uniq.length).to eq(10) end + + it 'does not generate codes with offensive patterns' do + # Generate many codes to check filtering works + codes = 100.times.map { described_class.generate } + + codes.each do |code| + first_cv = code[0, 2] + second_cv = code[4, 2] + + expect(JoinCodeGenerator::OFFENSIVE_PATTERNS).not_to include(first_cv) + expect(JoinCodeGenerator::OFFENSIVE_PATTERNS).not_to include(second_cv) + end + end end end