diff --git a/app/controllers/api/event_preferences_controller.rb b/app/controllers/api/event_preferences_controller.rb index aa34bbd7..527fe3b1 100644 --- a/app/controllers/api/event_preferences_controller.rb +++ b/app/controllers/api/event_preferences_controller.rb @@ -87,6 +87,9 @@ def set_preferenceable id = params[:google_calendar_event_id] scope = GoogleCalendarEvent.eager_load(meeting_time: { course: :faculties }) @preferenceable = id.to_s.include?("_") ? scope.find_by_public_id!(id) : scope.find(id) + # A GoogleCalendarEvent belongs to a specific user's calendar; enforce + # ownership here so another user's event id can't leak preference data. + authorize @preferenceable, :show? else render json: { error: "Preferenceable not specified" }, status: :bad_request end diff --git a/app/controllers/api/university_calendar_events_controller.rb b/app/controllers/api/university_calendar_events_controller.rb index b9e6201c..6d236284 100644 --- a/app/controllers/api/university_calendar_events_controller.rb +++ b/app/controllers/api/university_calendar_events_controller.rb @@ -6,7 +6,7 @@ class UniversityCalendarEventsController < ApiController def index authorize UniversityCalendarEvent, :index? - @events = policy_scope(UniversityCalendarEvent).upcoming.order(:start_time) + @events = policy_scope(UniversityCalendarEvent).includes(:term).upcoming.order(:start_time) @events = @events.where(category: params[:category]) if params[:category].present? @@ -52,7 +52,7 @@ def categories def holidays authorize UniversityCalendarEvent, :index? - @holidays = UniversityCalendarEvent.holidays.upcoming.order(:start_time) + @holidays = UniversityCalendarEvent.holidays.includes(:term).upcoming.order(:start_time) if params[:term_id].present? term = Term.find_by_public_id(params[:term_id]) diff --git a/app/controllers/api/users_controller.rb b/app/controllers/api/users_controller.rb index b974e9ef..4ff3d2f1 100644 --- a/app/controllers/api/users_controller.rb +++ b/app/controllers/api/users_controller.rb @@ -5,33 +5,32 @@ class UsersController < ApiController skip_before_action :authenticate_user_from_token!, only: [ :onboard ] # POST /api/user/onboard + # + # The extension sends a Google OAuth access token for the user's (personal) + # Google account — the same account they sync calendars to. We verify it with + # Google and key the account to that verified email, so a caller can only ever + # reach the account tied to a Google identity they actually control (no + # domain restriction — personal accounts are the norm). See GoogleTokenVerifier. def onboard - email = params[:email] + access_token = params[:google_access_token] || params[:access_token] preferred_name = params[:preferred_name] - if email.blank? - render json: { error: "email is required" }, status: :bad_request + if access_token.blank? + render json: { error: "google_access_token is required" }, status: :bad_request return end - if preferred_name.blank? - render json: { error: "preferred_name is required" }, status: :bad_request + verification = GoogleTokenVerifier.verify_access_token(access_token) + unless verification.success? + Rails.logger.warn("Onboard token verification failed: #{verification.error}") + render json: { error: "Invalid Google token" }, status: :unauthorized return end - name_parts = preferred_name.strip.split(" ", 2) - first_name = name_parts[0] - last_name = name_parts[1] + google_email = verification.email + user = find_or_create_onboarding_user(google_email, preferred_name, params[:wit_email]) - user = User.find_by(email: email.to_s.strip.downcase) || - User.create!( - email: email.to_s.strip.downcase, - first_name: first_name, - last_name: last_name, - password: SecureRandom.hex(24) - ) - - token = JsonWebTokenService.encode({ user_id: user.id }, nil) + token = JsonWebTokenService.encode({ user_id: user.id }) render json: { pub_id: user.public_id.delete_prefix("usr_"), jwt: token }, status: :ok rescue => e @@ -240,7 +239,7 @@ def get_processed_events_by_term .where(term_id: term.id) .includes(course: [ :faculties, - { meeting_times: [ :event_preference, { course: :faculties } ] } + { meeting_times: [ :event_preference, { rooms: :building }, { course: :faculties } ] } ]) preference_resolver = PreferenceResolver.new(current_user) @@ -358,5 +357,43 @@ def enable_notifications Rails.logger.error("Error enabling notifications for user #{current_user.id}: #{e.message}") render json: { error: "Failed to enable notifications" }, status: :internal_server_error end + + private + + # Resolves the account for a verified Google email, in this order: + # 1. a user already keyed to that Google email + # 2. a user who has connected that Google account for calendar sync + # (oauth_credentials are OAuth-verified, so this link is trustworthy) + # 3. otherwise, a fresh account keyed to the Google email + # + # Note: we deliberately do NOT link by a client-supplied WIT email — that + # value isn't verified, so trusting it would let a caller attach their token + # to someone else's existing account. Legacy accounts that never connected a + # Google account can't be auto-linked safely and start fresh. + def find_or_create_onboarding_user(google_email, preferred_name, wit_email = nil) + wit_email = wit_email.to_s.strip.downcase.presence + + existing = User.find_by(email: google_email) || + User.joins(:oauth_credentials) + .find_by(oauth_credentials: { email: google_email, provider: "google" }) + if existing + # Record the WIT email as metadata on the caller's OWN account (resolved + # from the verified token). It's never used to decide which account this + # is, so a forged value can only mislabel the caller's own record. + if wit_email && existing.wit_email != wit_email + existing.update_column(:wit_email, wit_email) # rubocop:disable Rails/SkipsModelValidations + end + return existing + end + + first_name, last_name = preferred_name.to_s.strip.split(" ", 2) + User.create!( + email: google_email, + wit_email: wit_email, + first_name: first_name, + last_name: last_name, + password: SecureRandom.hex(24) + ) + end end end diff --git a/app/controllers/calendars_controller.rb b/app/controllers/calendars_controller.rb index 1034259b..2e4c7729 100644 --- a/app/controllers/calendars_controller.rb +++ b/app/controllers/calendars_controller.rb @@ -118,7 +118,9 @@ def generate_ical(courses, final_exams) rule = IceCube::Rule.weekly.day(day_sym).until(until_time) e.rrule = rule.to_ical.gsub(/UNTIL=(\d{8})T\d{6}Z?/, 'UNTIL=\1') else - until_time = Time.utc(recurrence_end.year, recurrence_end.month, recurrence_end.day, 23, 59, 59) + # End-of-day in local (Eastern) zone as UTC, so evening classes keep + # their final occurrence instead of it falling past a bare-UTC UNTIL. + until_time = Time.zone.local(recurrence_end.year, recurrence_end.month, recurrence_end.day, 23, 59, 59).utc rule = IceCube::Rule.weekly.day(day_sym).until(until_time) e.rrule = rule.to_ical end diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb deleted file mode 100644 index f54a8b70..00000000 --- a/app/controllers/users/registrations_controller.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -class Users::RegistrationsController < Devise::RegistrationsController - before_action :configure_sign_up_params, only: [ :create ] - # before_action :configure_account_update_params, only: [:update] - - # GET /resource/sign_up - # def new - # super - # end - - # POST /resource - def create - super - end - - # GET /resource/edit - # def edit - # super - # end - - # PUT /resource - # def update - # super - # end - - # DELETE /resource - # def destroy - # super - # end - - # GET /resource/cancel - # Forces the session data which is usually expired after sign - # in to be expired now. This is useful if the user wants to - # cancel oauth signing in/up in the middle of the process, - # removing all OAuth session data. - # def cancel - # super - # end - - # protected - - # If you have extra params to permit, append them to the sanitizer. - def configure_sign_up_params - devise_parameter_sanitizer.permit(:sign_up, keys: [ :first_name, :last_name ]) - end - - # If you have extra params to permit, append them to the sanitizer. - # def configure_account_update_params - # devise_parameter_sanitizer.permit(:account_update, keys: [:attribute]) - # end - - # The path used after sign up. - # def after_sign_up_path_for(resource) - # super(resource) - # end - - # The path used after sign up for inactive accounts. - # def after_inactive_sign_up_path_for(resource) - # super(resource) - # end -end diff --git a/app/jobs/google_calendar_create_job.rb b/app/jobs/google_calendar_create_job.rb index ce0deff0..cf3d33e3 100644 --- a/app/jobs/google_calendar_create_job.rb +++ b/app/jobs/google_calendar_create_job.rb @@ -19,6 +19,10 @@ def perform(user_id) end end - user.sync_course_schedule(force: true) + # Route the follow-up sync through GoogleCalendarSyncJob rather than syncing + # inline: that job carries limits_concurrency keyed per user, so a + # creation-triggered sync can't race a concurrent sync and double-create + # remote events. + GoogleCalendarSyncJob.perform_later(user, force: true) end end diff --git a/app/jobs/google_calendar_event_delete_job.rb b/app/jobs/google_calendar_event_delete_job.rb new file mode 100644 index 00000000..30ef8242 --- /dev/null +++ b/app/jobs/google_calendar_event_delete_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Deletes a single event from a Google Calendar. Enqueued when a +# GoogleCalendarEvent DB row is destroyed (e.g. its meeting time was removed +# during reconcile) so the live Google event is removed too, rather than left +# behind as an untracked orphan. +class GoogleCalendarEventDeleteJob < ApplicationJob + queue_as :high + + def perform(calendar_id, google_event_id) + return if calendar_id.blank? || google_event_id.blank? + + GoogleCalendarService.new.delete_calendar_event(calendar_id, google_event_id) + end +end diff --git a/app/models/calendar_preference.rb b/app/models/calendar_preference.rb index 76891a41..fe70ce7d 100644 --- a/app/models/calendar_preference.rb +++ b/app/models/calendar_preference.rb @@ -19,8 +19,9 @@ # # Indexes # -# index_calendar_preferences_on_user_id (user_id) -# index_calendar_prefs_on_user_scope_type (user_id,scope,event_type) UNIQUE +# index_calendar_preferences_on_user_id (user_id) +# index_calendar_prefs_on_user_scope_type (user_id,scope,event_type) UNIQUE +# index_calendar_prefs_one_global_per_user (user_id) UNIQUE WHERE (scope = 0) # # Foreign Keys # @@ -41,6 +42,10 @@ class CalendarPreference < ApplicationRecord validates :scope, presence: true validates :event_type, presence: true, if: -> { scope_event_type? || scope_uni_cal_category? } validates :event_type, absence: true, if: :scope_global? + # Exactly one global preference per user. The DB unique index on + # (user_id, scope, event_type) can't enforce this because event_type is NULL + # for global rows and Postgres treats NULLs as distinct. + validate :only_one_global_preference, if: :scope_global? validates :event_type, inclusion: { in: UNI_CAL_CATEGORIES }, if: :scope_uni_cal_category? validates :title_template, length: { maximum: 500 }, allow_blank: true validates :description_template, length: { maximum: 2000 }, allow_blank: true @@ -57,6 +62,12 @@ class CalendarPreference < ApplicationRecord private + def only_one_global_preference + existing = CalendarPreference.where(user_id: user_id, scope: :global) + existing = existing.where.not(id: id) if persisted? + errors.add(:base, "a global preference already exists for this user") if existing.exists? + end + def validate_template_syntax [ :title_template, :description_template, :location_template ].each do |field| value = send(field) diff --git a/app/models/concerns/course_change_trackable.rb b/app/models/concerns/course_change_trackable.rb index cffe8e8e..a6c2411d 100644 --- a/app/models/concerns/course_change_trackable.rb +++ b/app/models/concerns/course_change_trackable.rb @@ -17,11 +17,28 @@ def self.with_enrollment_cache included do # Mark all enrolled users' calendars as needing sync when course details change after_save :mark_enrolled_users_for_sync, if: :saved_change_to_relevant_attributes? - after_destroy :mark_enrolled_users_for_sync + + # On destroy, the enrollments are removed by a dependent-destroy cascade + # (a before_destroy callback), so by the time an after_destroy hook runs the + # enrollment query returns nobody. Capture the affected users BEFORE the + # cascade (prepend), then flag them once the destroy commits. + before_destroy :capture_enrolled_users_for_sync, prepend: true + after_commit :flag_captured_enrolled_users, on: :destroy end private + def capture_enrolled_users_for_sync + @captured_enrolled_user_ids = enrolled_google_user_ids + end + + def flag_captured_enrolled_users + ids = @captured_enrolled_user_ids + return if ids.blank? + + User.where(id: ids).update_all(calendar_needs_sync: true) # rubocop:disable Rails/SkipsModelValidations + end + def saved_change_to_relevant_attributes? # Track changes to any attributes that affect calendar display relevant_attrs = %w[title start_date end_date subject course_number section_number] @@ -40,14 +57,17 @@ def mark_enrolled_users_for_sync end return unless has_enrollments - user_ids = User.joins(:enrollments) - .joins(:oauth_credentials) - .where(enrollments: { course_id: id }) - .where(oauth_credentials: { provider: "google" }) - .where("oauth_credentials.metadata->>'course_calendar_id' IS NOT NULL") - .distinct - .pluck(:id) - + user_ids = enrolled_google_user_ids User.where(id: user_ids).update_all(calendar_needs_sync: true) if user_ids.any? # rubocop:disable Rails/SkipsModelValidations end + + def enrolled_google_user_ids + User.joins(:enrollments) + .joins(:oauth_credentials) + .where(enrollments: { course_id: id }) + .where(oauth_credentials: { provider: "google" }) + .where("oauth_credentials.metadata->>'course_calendar_id' IS NOT NULL") + .distinct + .pluck(:id) + end end diff --git a/app/models/concerns/course_schedule_syncable.rb b/app/models/concerns/course_schedule_syncable.rb index 0141652e..2aca91fa 100644 --- a/app/models/concerns/course_schedule_syncable.rb +++ b/app/models/concerns/course_schedule_syncable.rb @@ -306,8 +306,10 @@ def build_recurrence_rule(meeting_time) end # Build weekly recurrence rule using ice_cube and export to iCalendar format. - # UNTIL is set to end-of-day UTC so the last occurrence is fully included. - until_time = Time.utc(recurrence_end.year, recurrence_end.month, recurrence_end.day, 23, 59, 59) + # UNTIL must be end-of-day in the *local* zone expressed as UTC. Using bare + # Time.utc(...,23,59,59) drops the final occurrence of evening (Eastern) + # classes, whose start instant falls after midnight UTC. + until_time = Time.zone.local(recurrence_end.year, recurrence_end.month, recurrence_end.day, 23, 59, 59).utc rule = IceCube::Rule.weekly.day(meeting_time.day_of_week.to_sym).until(until_time) "RRULE:#{rule.to_ical}" end @@ -645,7 +647,8 @@ def university_event_scope(time_scope) def tbd_room?(room) return false unless room - room.number == 0 + # Room#number is a string column, so compare against the string "0". + room.number.to_s == "0" # Note: Room model in production only has 'number', not 'name' # If room.name is added later, uncomment these lines: # room.name&.downcase&.include?("tbd") || diff --git a/app/models/concerns/meeting_time_change_trackable.rb b/app/models/concerns/meeting_time_change_trackable.rb index 4a354f11..d80bb2ea 100644 --- a/app/models/concerns/meeting_time_change_trackable.rb +++ b/app/models/concerns/meeting_time_change_trackable.rb @@ -20,11 +20,19 @@ def self.with_enrollment_cache after_destroy :mark_enrolled_users_for_sync end + # Public entry point for related records (e.g. the room join table) to flag + # this meeting time's enrolled users for a calendar resync. + def resync_enrolled_users! + mark_enrolled_users_for_sync + end + private def saved_change_to_relevant_attributes? - # Track changes to any attributes that affect calendar display - relevant_attrs = %w[begin_time end_time day_of_week start_date end_date room_id] + # Track changes to any attributes that affect calendar display. + # NOTE: rooms live in the course_meeting_time_rooms join table, not a + # room_id column here — room changes are flagged from that model instead. + relevant_attrs = %w[begin_time end_time day_of_week start_date end_date] relevant_attrs.any? { |attr| saved_change_to_attribute?(attr) } end diff --git a/app/models/course/meeting_time_room.rb b/app/models/course/meeting_time_room.rb index f1d691d1..0ff0710d 100644 --- a/app/models/course/meeting_time_room.rb +++ b/app/models/course/meeting_time_room.rb @@ -25,4 +25,15 @@ class Course::MeetingTimeRoom < ApplicationRecord belongs_to :meeting_time, class_name: "Course::MeetingTime" belongs_to :room + + # A room being added or removed changes the calendar event location, so flag + # the meeting time's enrolled users for resync (the meeting_time row itself + # doesn't change, so its own callbacks won't fire). + after_commit :resync_enrolled_users, on: [ :create, :destroy ] + + private + + def resync_enrolled_users + meeting_time&.resync_enrolled_users! + end end diff --git a/app/models/friendship.rb b/app/models/friendship.rb index e72affe7..31b37967 100644 --- a/app/models/friendship.rb +++ b/app/models/friendship.rb @@ -18,6 +18,7 @@ # index_friendships_on_requester_id (requester_id) # index_friendships_on_requester_id_and_addressee_id (requester_id,addressee_id) UNIQUE # index_friendships_on_requester_id_and_status (requester_id,status) +# index_friendships_on_unordered_pair (LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id)) UNIQUE # # Foreign Keys # diff --git a/app/models/google_calendar.rb b/app/models/google_calendar.rb index a96862f5..edc87935 100644 --- a/app/models/google_calendar.rb +++ b/app/models/google_calendar.rb @@ -16,9 +16,9 @@ # # Indexes # -# index_google_calendars_on_google_calendar_id (google_calendar_id) UNIQUE -# index_google_calendars_on_last_synced_at (last_synced_at) -# index_google_calendars_on_oauth_credential_id (oauth_credential_id) +# index_google_calendars_on_google_calendar_id (google_calendar_id) UNIQUE +# index_google_calendars_on_last_synced_at (last_synced_at) +# index_google_calendars_on_oauth_credential_id_unique (oauth_credential_id) UNIQUE # # Foreign Keys # @@ -34,6 +34,8 @@ class GoogleCalendar < ApplicationRecord has_one :user, through: :oauth_credential validates :google_calendar_id, presence: true, uniqueness: true + # One course calendar per OAuth credential — the app treats this as a has_one. + validates :oauth_credential_id, uniqueness: true before_destroy :enqueue_google_calendar_deletion diff --git a/app/models/google_calendar_event.rb b/app/models/google_calendar_event.rb index 671c458e..fca24c04 100644 --- a/app/models/google_calendar_event.rb +++ b/app/models/google_calendar_event.rb @@ -60,6 +60,13 @@ class GoogleCalendarEvent < ApplicationRecord serialize :recurrence, coder: JSON + # Set by the sync service when it has already deleted the remote event itself, + # so destroying the DB row doesn't enqueue a redundant deletion. + attr_accessor :skip_remote_deletion + + before_destroy :capture_remote_event_ref + after_commit :enqueue_remote_event_deletion, on: :destroy + TRACKABLE_FIELDS = %w[summary location description start_time end_time].freeze scope :for_meeting_time, ->(id) { where(meeting_time_id: id) } @@ -140,6 +147,26 @@ def clear_edited_fields!(fields = nil) private + # Records the (calendar, event) pair before the row is destroyed so we can + # delete the live Google event after the transaction commits. Skipped when the + # sync service already handled the remote delete, or when the whole calendar is + # being torn down (delete_calendar cleans up its events server-side). + def capture_remote_event_ref + return if skip_remote_deletion + return if destroyed_by_association&.foreign_key.to_s == "google_calendar_id" + + @remote_event_ref = [ google_calendar&.google_calendar_id, google_event_id ] + end + + def enqueue_remote_event_deletion + return if @remote_event_ref.blank? + + calendar_id, event_id = @remote_event_ref + return if calendar_id.blank? || event_id.blank? + + GoogleCalendarEventDeleteJob.perform_later(calendar_id, event_id) + end + def only_one_event_type_associated event_types = [ meeting_time_id, final_exam_id, university_calendar_event_id ].compact return unless event_types.size != 1 diff --git a/app/models/user.rb b/app/models/user.rb index 009a0b96..4b8c9815 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -29,6 +29,7 @@ # sign_in_count :integer default(0), not null # unconfirmed_email :string # unlock_token :string +# wit_email :string # created_at :datetime not null # updated_at :datetime not null # @@ -41,6 +42,7 @@ # index_users_on_email (email) UNIQUE # index_users_on_last_calendar_sync_at (last_calendar_sync_at) # index_users_on_reset_password_token (reset_password_token) UNIQUE +# index_users_on_wit_email (wit_email) # class User < ApplicationRecord include GoogleOauthable @@ -48,7 +50,10 @@ class User < ApplicationRecord include CalendarTokenable include EncodedIds::HashidIdentifiable - devise :database_authenticatable, :registerable, + # No :registerable — accounts are provisioned only via Google OAuth + # (see AuthController#handle_user_login, which enforces the @wit.edu gate). + # Self-service password signup would bypass that domain restriction. + devise :database_authenticatable, :recoverable, :rememberable, :validatable, :confirmable, :trackable, :timeoutable, :lockable set_public_id_prefix :usr diff --git a/app/models/user_extension_config.rb b/app/models/user_extension_config.rb index fd83b4fd..3b8d778f 100644 --- a/app/models/user_extension_config.rb +++ b/app/models/user_extension_config.rb @@ -19,7 +19,7 @@ # # Indexes # -# index_user_extension_configs_on_user_id (user_id) +# index_user_extension_configs_on_user_id_unique (user_id) UNIQUE # # Foreign Keys # @@ -32,6 +32,9 @@ class UserExtensionConfig < ApplicationRecord belongs_to :user + # One config per user (the app treats this as a has_one). + validates :user_id, uniqueness: true + before_save :clear_categories_when_sync_disabled after_update :sync_calendar_if_settings_changed diff --git a/app/serializers/calendar_preference_serializer.rb b/app/serializers/calendar_preference_serializer.rb index 62675248..51e09978 100644 --- a/app/serializers/calendar_preference_serializer.rb +++ b/app/serializers/calendar_preference_serializer.rb @@ -19,8 +19,9 @@ # # Indexes # -# index_calendar_preferences_on_user_id (user_id) -# index_calendar_prefs_on_user_scope_type (user_id,scope,event_type) UNIQUE +# index_calendar_preferences_on_user_id (user_id) +# index_calendar_prefs_on_user_scope_type (user_id,scope,event_type) UNIQUE +# index_calendar_prefs_one_global_per_user (user_id) UNIQUE WHERE (scope = 0) # # Foreign Keys # diff --git a/app/services/calendar_template_renderer.rb b/app/services/calendar_template_renderer.rb index a68c5888..a0ccde23 100644 --- a/app/services/calendar_template_renderer.rb +++ b/app/services/calendar_template_renderer.rb @@ -217,10 +217,32 @@ def format_datetime(datetime) datetime.strftime("%-I:%M %p") end - def check_for_disallowed_tags(_parsed_template) + # Templates may only contain plain text and {{ variable }} interpolation. + # Liquid tags ({% ... %}) — loops, includes, conditionals — are rejected so + # a user-supplied template can't do control flow, resource exhaustion, or + # pull in other content. + def check_for_disallowed_tags(parsed_template) + tags = collect_tag_names(parsed_template.root) + if tags.any? + raise InvalidTemplateError, "Disallowed tags: #{tags.uniq.join(', ')}" + end + true end + def collect_tag_names(node, names = []) + return names unless node.respond_to?(:nodelist) && node.nodelist + + node.nodelist.each do |child| + if child.is_a?(Liquid::Tag) + names << (child.respond_to?(:tag_name) ? child.tag_name : child.class.name.demodulize.underscore) + end + collect_tag_names(child, names) + end + + names + end + def extract_variables(parsed_template) variables = Set.new extract_variables_from_node(parsed_template.root, variables) diff --git a/app/services/catalog_import_service.rb b/app/services/catalog_import_service.rb index 7834f25d..27aa3c8a 100644 --- a/app/services/catalog_import_service.rb +++ b/app/services/catalog_import_service.rb @@ -154,10 +154,15 @@ def process_course(course_data) end if meeting_times.any? - MeetingTimesIngestService.call( + kept_ids = MeetingTimesIngestService.call( course: course, raw_meeting_times: meeting_times ) + + # Remove meeting times that no longer exist upstream (e.g. Banner changed a + # section's day/time), but only when the ingest produced rows — never wipe + # the course from an empty result. Preserves untouched rows and their events. + course.meeting_times.where.not(id: kept_ids).destroy_all if kept_ids.any? else Rails.logger.warn("No meeting times found for course CRN #{crn}") end diff --git a/app/services/course_processor_service.rb b/app/services/course_processor_service.rb index aae0a7db..c3c0887a 100644 --- a/app/services/course_processor_service.rb +++ b/app/services/course_processor_service.rb @@ -191,13 +191,18 @@ def call Rails.logger.info("Linked FinalExam for CRN #{course.crn} to course #{course.id}") end - course.meeting_times.destroy_all - - MeetingTimesIngestService.call( + # Reconcile rather than destroy_all: the Course/MeetingTime graph is + # shared across every student in the CRN, and destroying it would orphan + # all their Google Calendar events. Upsert by content key, then remove + # only rows the ingest didn't touch — and only when the scrape actually + # produced meeting times, so a partial/empty scrape can't wipe the course. + kept_ids = MeetingTimesIngestService.call( course: course, raw_meeting_times: meeting_times ) + course.meeting_times.where.not(id: kept_ids).destroy_all if kept_ids.any? + process_faculty(course, faculty_data) Enrollment.find_or_create_by!(user: user, course: course, term: term) diff --git a/app/services/google_calendar_service.rb b/app/services/google_calendar_service.rb index 2083a13b..d11a572a 100644 --- a/app/services/google_calendar_service.rb +++ b/app/services/google_calendar_service.rb @@ -183,6 +183,19 @@ def list_calendars with_rate_limit_handling { service.list_calendar_lists } end + # Deletes a single event from a calendar using the service account (which owns + # all app-created calendars). Used when a GoogleCalendarEvent row is destroyed + # so the live Google event doesn't linger as an orphan. Treats a missing event + # as success. + def delete_calendar_event(calendar_id, google_event_id) + service = service_account_calendar_service + with_rate_limit_handling { service.delete_event(calendar_id, google_event_id) } + rescue Google::Apis::ClientError => e + raise unless e.status_code == 404 + + Rails.logger.info("Event #{google_event_id} already absent from calendar #{calendar_id}") + end + def delete_calendar(calendar_id) google_calendar = GoogleCalendar.find_by(google_calendar_id: calendar_id) @@ -432,6 +445,14 @@ def create_event_in_calendar(service, google_calendar, course_event, preference_ end google_calendar.google_calendar_events.create!(event_attributes) + rescue ActiveRecord::RecordNotUnique + # A concurrent sync already created the tracking row for this event, so the + # insert_event above produced a duplicate remote event. Remove the duplicate + # we just created rather than leaving it orphaned on the calendar. + Rails.logger.warn({ message: "Duplicate event race — removing redundant remote event", + user_id: user&.id, calendar_id: calendar_id, google_event_id: created_event&.id }.to_json) + with_rate_limit_handling { service.delete_event(calendar_id, created_event.id) } if created_event&.id + nil end def update_event_in_calendar(service, google_calendar, db_event, course_event, force: false, preference_resolver: nil, template_renderer: nil) @@ -512,12 +533,14 @@ def update_event_in_calendar(service, google_calendar, db_event, course_event, f def delete_event_from_calendar(service, google_calendar, db_event) calendar_id = google_calendar.google_calendar_id with_rate_limit_handling { service.delete_event(calendar_id, db_event.google_event_id) } + db_event.skip_remote_deletion = true db_event.destroy rescue Google::Apis::ClientError => e raise unless e.status_code == 404 Rails.logger.warn({ message: "Event not found in Google Calendar, removing from database", user_id: user.id, google_event_id: db_event.google_event_id }.to_json) + db_event.skip_remote_deletion = true db_event.destroy end diff --git a/app/services/google_token_verifier.rb b/app/services/google_token_verifier.rb new file mode 100644 index 00000000..ad68abd8 --- /dev/null +++ b/app/services/google_token_verifier.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# Verifies a Google-issued OAuth access token server-side. +# +# The Chrome extension obtains a token for the signed-in Google user (via +# chrome.identity) and sends it to /api/user/onboard. We validate it against +# Google's tokeninfo endpoint, confirm it was issued to *our* OAuth client +# (audience check, prevents token replay from other apps), and return the +# Google-verified email. This is what lets onboard trust the email instead of +# accepting whatever string the caller sends. +class GoogleTokenVerifier + TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo" + OPEN_TIMEOUT = 5 + READ_TIMEOUT = 5 + + Result = Struct.new(:success, :email, :email_verified, :error, keyword_init: true) do + def success? + success + end + end + + def self.verify_access_token(access_token) + new.verify_access_token(access_token) + end + + def verify_access_token(access_token) + return failure("missing token") if access_token.blank? + + response = connection.get(TOKENINFO_URL, access_token: access_token) + + unless response.success? + return failure("token rejected by Google (status #{response.status})") + end + + info = response.body + info = JSON.parse(info) if info.is_a?(String) + + audience = info["aud"] || info["azp"] || info["audience"] || info["issued_to"] + unless audience.present? && allowed_client_ids.include?(audience) + return failure("token audience mismatch") + end + + email = info["email"].to_s.strip.downcase + return failure("token has no email") if email.blank? + + Result.new( + success: true, + email: email, + email_verified: info["email_verified"].to_s == "true" || info["verified_email"].to_s == "true" + ) + rescue Faraday::Error, JSON::ParserError => e + failure("token verification failed: #{e.message}") + end + + private + + # The token may be issued to the web OAuth client (dashboard/calendar flow) or + # the Chrome extension's own OAuth client. Both are accepted; add the + # extension client id to GOOGLE_OAUTH_CLIENT_IDS (comma-separated). Mirrors the + # audience allowlist used by RiscValidationService. + def allowed_client_ids + @allowed_client_ids ||= begin + ids = ENV["GOOGLE_OAUTH_CLIENT_IDS"].to_s.split(",").map(&:strip).reject(&:blank?) + web_client_id = Rails.application.credentials.dig(:google, :client_id) + ids << web_client_id if web_client_id.present? + ids.uniq + end + end + + def failure(message) + Result.new(success: false, error: message) + end + + def connection + @connection ||= Faraday.new do |f| + f.options.open_timeout = OPEN_TIMEOUT + f.options.timeout = READ_TIMEOUT + f.response :json, content_type: /\bjson$/ + end + end +end diff --git a/app/services/json_web_token_service.rb b/app/services/json_web_token_service.rb index 805602ff..54063ed7 100644 --- a/app/services/json_web_token_service.rb +++ b/app/services/json_web_token_service.rb @@ -5,18 +5,22 @@ class JsonWebTokenService Rails.application.credentials.secret_key_base || Rails.application.secret_key_base - def self.encode(payload, exp = 24.hours.from_now) - payload[:exp] = exp.to_i if exp.present? + # Default lifetime for API tokens. Every token gets an expiry — a token + # without an `exp` claim would never expire, which is a security hazard. + DEFAULT_TTL = 14.days + + def self.encode(payload, exp = DEFAULT_TTL.from_now) + raise ArgumentError, "JWT expiry is required" if exp.blank? + + payload = payload.dup + payload[:exp] = exp.to_i JWT.encode(payload, SECRET_KEY) end + # Verifies signature (HS256) and expiration. Returns nil for any invalid, + # tampered, or expired token. def self.decode(token) - decoded = JWT.decode(token, SECRET_KEY, true, { verify_expiration: false, algorithm: "HS256" })[0] - - if decoded["exp"] - decoded = JWT.decode(token, SECRET_KEY, true, { algorithm: "HS256" })[0] - end - + decoded = JWT.decode(token, SECRET_KEY, true, { algorithm: "HS256" })[0] ActiveSupport::HashWithIndifferentAccess.new(decoded) rescue JWT::DecodeError, JWT::ExpiredSignature nil diff --git a/app/services/meeting_times_ingest_service.rb b/app/services/meeting_times_ingest_service.rb index ac9c970f..79e65f95 100644 --- a/app/services/meeting_times_ingest_service.rb +++ b/app/services/meeting_times_ingest_service.rb @@ -9,14 +9,20 @@ def initialize(course:, raw_meeting_times:, **options) @compute_hours_week = options.fetch(:compute_hours_week, true) @building_cache = {} @room_cache = {} + @kept_meeting_time_ids = [] super() end + # Upserts meeting times by content key (never destroys) and returns the ids of + # the meeting times it created or updated. Callers use this to reconcile — + # deleting only the stale rows the ingest didn't touch — instead of a blanket + # destroy_all, which would orphan every enrolled student's calendar events. def call preload_buildings_and_rooms raw_meeting_times.each do |mt| ingest_one(mt) end + @kept_meeting_time_ids.uniq end private @@ -158,6 +164,7 @@ def ingest_one(mt) meeting_time = @meeting_time_cache&.[](cache_key) || Course::MeetingTime.new(lookup_attrs) meeting_time.assign_attributes(update_attrs) meeting_time.save! + @kept_meeting_time_ids << meeting_time.id desired_room_ids = rooms_for_mt.map(&:id).to_set existing_room_ids = meeting_time.meeting_time_rooms.map(&:room_id).to_set diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb deleted file mode 100644 index 19bb019b..00000000 --- a/app/views/devise/registrations/edit.html.erb +++ /dev/null @@ -1,42 +0,0 @@ -

Edit <%= resource_name.to_s.humanize %>

- -<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %> - <%= render "devise/shared/error_messages", resource: resource %> - -
-

<%= f.label :email %>

-

<%= f.email_field :email, autofocus: true, autocomplete: "email" %>

-
- - <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %> -
Currently waiting confirmation for: <%= resource.unconfirmed_email %>
- <% end %> - -
-

<%= f.label :password %> (leave blank if you don't want to change it)

-

<%= f.password_field :password, autocomplete: "new-password" %>

- <% if @minimum_password_length %> -

<%= @minimum_password_length %> characters minimum

- <% end %> -
- -
-

<%= f.label :password_confirmation %>

-

<%= f.password_field :password_confirmation, autocomplete: "new-password" %>

-
- -
-

<%= f.label :current_password %> (we need your current password to confirm your changes)

-

<%= f.password_field :current_password, autocomplete: "current-password" %>

-
- -
- <%= f.submit "Update" %> -
-<% end %> - -

Cancel my account

- -
Unhappy? <%= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?" }, method: :delete %>
- -<%= link_to "Back", :back %> diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb deleted file mode 100644 index b92a3ec8..00000000 --- a/app/views/devise/registrations/new.html.erb +++ /dev/null @@ -1,39 +0,0 @@ -

Sign up

- -<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %> - <%= render "devise/shared/error_messages", resource: resource %> - -
- <%= f.label :first_name %>
- <%= f.text_field :first_name %> -
- -
- <%= f.label :last_name %>
- <%= f.text_field :last_name %> -
- -
-

<%= f.label :email %>

-

<%= f.email_field :email, autofocus: true, autocomplete: "email" %>

-
- -
-

<%= f.label :password %>

- <% if @minimum_password_length %> -

(<%= @minimum_password_length %> characters minimum)

- <% end %> -

<%= f.password_field :password, autocomplete: "new-password" %>

-
- -
-

<%= f.label :password_confirmation %>

-

<%= f.password_field :password_confirmation, autocomplete: "new-password" %>

-
- -
- <%= f.submit "Sign up" %> -
-<% end %> - -<%= render "devise/shared/links" %> diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 05516da1..a799c9a2 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -155,9 +155,11 @@ def self.extract_user_id_from_jwt(req) return nil unless auth_header&.start_with?("Bearer ") token = auth_header.split.last - payload = JWT.decode(token, nil, false).first - payload["user_id"] - rescue JWT::DecodeError, StandardError + # Verify signature and expiration. A forged/unsigned token must not be able + # to grant the admin throttle safelist or a controlled throttle-bucket key. + payload = JsonWebTokenService.decode(token) + payload && payload[:user_id] + rescue StandardError nil end end diff --git a/config/routes.rb b/config/routes.rb index a263d15b..2cd25f48 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true Rails.application.routes.draw do - devise_for :users, controllers: { - sessions: "users/sessions", - registrations: "users/registrations" - } + devise_for :users, + controllers: { sessions: "users/sessions" }, + skip: [ :registrations ] get "up" => "rails/health#show", as: :rails_health_check diff --git a/db/migrate/20260701200457_enforce_has_one_uniqueness.rb b/db/migrate/20260701200457_enforce_has_one_uniqueness.rb new file mode 100644 index 00000000..d2cf0040 --- /dev/null +++ b/db/migrate/20260701200457_enforce_has_one_uniqueness.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +# Backs the app-level has_one uniqueness validations with real DB constraints, +# so concurrent requests can't slip duplicate rows past a check-then-act. +# +# Each index is preceded by a conservative de-dup that keeps the earliest row +# (lowest id) of any existing duplicate group, using raw SQL delete to avoid +# firing destroy callbacks (notably GoogleCalendar#before_destroy, which would +# delete the remote Google calendar). Orphaned child rows left behind by removed +# google_calendars are handled by the existing orphan-cleanup jobs. +class EnforceHasOneUniqueness < ActiveRecord::Migration[8.1] + def up + # --- user_extension_configs: one per user ------------------------------ + dedupe("user_extension_configs", "user_id") + add_index :user_extension_configs, :user_id, unique: true, + name: "index_user_extension_configs_on_user_id_unique" + remove_index :user_extension_configs, name: "index_user_extension_configs_on_user_id", if_exists: true + + # --- google_calendars: one course calendar per oauth credential -------- + dedupe("google_calendars", "oauth_credential_id") + add_index :google_calendars, :oauth_credential_id, unique: true, + name: "index_google_calendars_on_oauth_credential_id_unique" + remove_index :google_calendars, name: "index_google_calendars_on_oauth_credential_id", if_exists: true + + # --- calendar_preferences: one global preference per user -------------- + # Partial unique index — the existing (user_id, scope, event_type) index + # can't enforce this because event_type is NULL for global rows. + execute(<<~SQL) + DELETE FROM calendar_preferences a + USING calendar_preferences b + WHERE a.scope = 0 AND b.scope = 0 + AND a.user_id = b.user_id + AND a.id > b.id; + SQL + add_index :calendar_preferences, :user_id, unique: true, + where: "scope = 0", + name: "index_calendar_prefs_one_global_per_user" + + # --- friendships: one friendship per unordered user pair --------------- + # Functional index on the sorted pair blocks both (A,B) and its reverse (B,A). + execute(<<~SQL) + DELETE FROM friendships a + USING friendships b + WHERE LEAST(a.requester_id, a.addressee_id) = LEAST(b.requester_id, b.addressee_id) + AND GREATEST(a.requester_id, a.addressee_id) = GREATEST(b.requester_id, b.addressee_id) + AND a.id > b.id; + SQL + execute(<<~SQL) + CREATE UNIQUE INDEX index_friendships_on_unordered_pair + ON friendships (LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id)); + SQL + end + + def down + execute("DROP INDEX IF EXISTS index_friendships_on_unordered_pair;") + remove_index :calendar_preferences, name: "index_calendar_prefs_one_global_per_user", if_exists: true + + add_index :google_calendars, :oauth_credential_id, + name: "index_google_calendars_on_oauth_credential_id", if_not_exists: true + remove_index :google_calendars, name: "index_google_calendars_on_oauth_credential_id_unique", if_exists: true + + add_index :user_extension_configs, :user_id, + name: "index_user_extension_configs_on_user_id", if_not_exists: true + remove_index :user_extension_configs, name: "index_user_extension_configs_on_user_id_unique", if_exists: true + end + + private + + # Deletes duplicate rows for a column, keeping the lowest id in each group. + # Uses raw SQL delete so no ActiveRecord destroy callbacks fire. + def dedupe(table, column) + execute(<<~SQL) + DELETE FROM #{table} a + USING #{table} b + WHERE a.#{column} = b.#{column} + AND a.id > b.id; + SQL + end +end diff --git a/db/migrate/20260701205227_add_wit_email_to_users.rb b/db/migrate/20260701205227_add_wit_email_to_users.rb new file mode 100644 index 00000000..a63e718a --- /dev/null +++ b/db/migrate/20260701205227_add_wit_email_to_users.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Stores the WIT email scraped from the user's authenticated WIT session as +# metadata (which student the account belongs to). NOT unique and NOT used for +# authentication/account resolution — the value is client-supplied and can't be +# verified server-side, so trusting it for identity would reopen the takeover. +# Indexed only for admin/support lookups. +class AddWitEmailToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :wit_email, :string + add_index :users, :wit_email + end +end diff --git a/db/schema.rb b/db/schema.rb index f37425c9..7b6c9ac3 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[8.1].define(version: 2026_06_18_033320) do +ActiveRecord::Schema[8.1].define(version: 2026_07_01_205227) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -125,6 +125,7 @@ t.string "visibility" t.index ["user_id", "scope", "event_type"], name: "index_calendar_prefs_on_user_scope_type", unique: true t.index ["user_id"], name: "index_calendar_preferences_on_user_id" + t.index ["user_id"], name: "index_calendar_prefs_one_global_per_user", unique: true, where: "(scope = 0)" end create_table "console1984_commands", force: :cascade do |t| @@ -363,6 +364,7 @@ t.bigint "requester_id", null: false t.integer "status", default: 0, null: false t.datetime "updated_at", null: false + t.index "LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id)", name: "index_friendships_on_unordered_pair", unique: true t.index ["addressee_id", "status"], name: "index_friendships_on_addressee_id_and_status" t.index ["addressee_id"], name: "index_friendships_on_addressee_id" t.index ["requester_id", "addressee_id"], name: "index_friendships_on_requester_id_and_addressee_id", unique: true @@ -409,7 +411,7 @@ t.datetime "updated_at", null: false t.index ["google_calendar_id"], name: "index_google_calendars_on_google_calendar_id", unique: true t.index ["last_synced_at"], name: "index_google_calendars_on_last_synced_at" - t.index ["oauth_credential_id"], name: "index_google_calendars_on_oauth_credential_id" + t.index ["oauth_credential_id"], name: "index_google_calendars_on_oauth_credential_id_unique", unique: true end create_table "oauth_credentials", force: :cascade do |t| @@ -754,7 +756,7 @@ t.jsonb "university_event_categories" t.datetime "updated_at", null: false t.bigint "user_id", null: false - t.index ["user_id"], name: "index_user_extension_configs_on_user_id" + t.index ["user_id"], name: "index_user_extension_configs_on_user_id_unique", unique: true end create_table "users", force: :cascade do |t| @@ -784,6 +786,7 @@ t.string "unconfirmed_email" t.string "unlock_token" t.datetime "updated_at", null: false + t.string "wit_email" t.index ["access_level"], name: "index_users_on_access_level" t.index ["calendar_needs_sync"], name: "index_users_on_calendar_needs_sync" t.index ["calendar_token"], name: "index_users_on_calendar_token", unique: true @@ -791,6 +794,7 @@ t.index ["email"], name: "index_users_on_email", unique: true t.index ["last_calendar_sync_at"], name: "index_users_on_last_calendar_sync_at" t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true + t.index ["wit_email"], name: "index_users_on_wit_email" end add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" diff --git a/lib/tasks/catalog_refresh.rake b/lib/tasks/catalog_refresh.rake index cfb51dc2..5c463f83 100644 --- a/lib/tasks/catalog_refresh.rake +++ b/lib/tasks/catalog_refresh.rake @@ -48,51 +48,70 @@ namespace :catalog do end puts "Backed up #{backup_count} enrollments with #{error_count} errors." - # Step 2: Delete course data - puts "\nStep 2: Deleting course data for term #{term.name}..." - courses_for_term = Course.where(term: term) - course_ids_for_term = courses_for_term.pluck(:id) - - puts "Orphaning Google Calendar events..." - meeting_time_ids = Course::MeetingTime.where(course_id: course_ids_for_term).pluck(:id) - GoogleCalendarEvent.where(meeting_time_id: meeting_time_ids).update_all(meeting_time_id: nil) - - puts "Deleting meeting times..." - Course::MeetingTime.where(course_id: course_ids_for_term).delete_all - - puts "Deleting enrollments..." - Enrollment.where(term_id: term.id).delete_all - - puts "Deleting course-faculty associations..." - courses_for_term.each { |c| c.faculties.clear } - - puts "Deleting courses..." - delete_count = courses_for_term.delete_all - puts "Deleted #{delete_count} courses." + # Step 2: Pre-flight fetch — get the fresh catalog BEFORE deleting anything. + # If the scrape fails or returns no courses (e.g. LeopardWeb markup changed), + # abort now so we never wipe a term's data with nothing to replace it. + puts "\nStep 2: Fetching fresh catalog data from LeopardWeb (pre-flight)..." + result = LeopardWebService.get_course_catalog(term: term_uid) + unless result[:success] + raise "Aborting: failed to fetch catalog for term #{term.name}: #{result[:error]}. No data was deleted." + end - # Step 3: Re-import - puts "\nStep 3: Importing fresh catalog data from LeopardWeb..." - CatalogImportJob.perform_now(term_uid) + fresh_courses = result[:courses] || [] + if fresh_courses.empty? + raise "Aborting: LeopardWeb returned 0 courses for term #{term.name}. No data was deleted." + end + puts "Fetched #{fresh_courses.count} courses. Proceeding with refresh." - # Step 4: Restore enrollments - puts "\nStep 4: Restoring enrollments for term #{term.name}..." + # Steps 3–4 run in a single transaction so any failure during delete/import + # rolls back the deletions, leaving the existing catalog intact. restored_count = 0 restore_error_count = 0 not_found_count = 0 snapshots = EnrollmentSnapshot.where(term_id: term.id, snapshot_reason: snapshot_reason) - snapshots.includes(:user, :term).find_each do |snapshot| - begin - course = Course.find_by(crn: snapshot.crn, term_id: snapshot.term_id) - if course - Enrollment.find_or_create_by!(user_id: snapshot.user_id, course_id: course.id, term_id: snapshot.term_id) - restored_count += 1 - else - not_found_count += 1 + ActiveRecord::Base.transaction do + # Step 3: Delete course data + puts "\nStep 3: Deleting course data for term #{term.name}..." + courses_for_term = Course.where(term: term) + course_ids_for_term = courses_for_term.pluck(:id) + + puts "Orphaning Google Calendar events..." + meeting_time_ids = Course::MeetingTime.where(course_id: course_ids_for_term).pluck(:id) + GoogleCalendarEvent.where(meeting_time_id: meeting_time_ids).update_all(meeting_time_id: nil) + + puts "Deleting meeting times..." + Course::MeetingTime.where(course_id: course_ids_for_term).delete_all + + puts "Deleting enrollments..." + Enrollment.where(term_id: term.id).delete_all + + puts "Deleting course-faculty associations..." + courses_for_term.each { |c| c.faculties.clear } + + puts "Deleting courses..." + delete_count = courses_for_term.delete_all + puts "Deleted #{delete_count} courses." + + # Re-import using the already-fetched data (no second HTTP call). + puts "\nImporting fresh catalog data..." + CatalogImportService.new(fresh_courses).call! + + # Step 4: Restore enrollments + puts "\nStep 4: Restoring enrollments for term #{term.name}..." + snapshots.includes(:user, :term).find_each do |snapshot| + begin + course = Course.find_by(crn: snapshot.crn, term_id: snapshot.term_id) + if course + Enrollment.find_or_create_by!(user_id: snapshot.user_id, course_id: course.id, term_id: snapshot.term_id) + restored_count += 1 + else + not_found_count += 1 + end + rescue => e + puts "\nError restoring enrollment for user #{snapshot.user_id}, CRN #{snapshot.crn}: #{e.message}" + restore_error_count += 1 end - rescue => e - puts "\nError restoring enrollment for user #{snapshot.user_id}, CRN #{snapshot.crn}: #{e.message}" - restore_error_count += 1 end end puts "Restored #{restored_count} enrollments. #{not_found_count} courses not found. #{restore_error_count} errors." diff --git a/lib/tasks/finals_cleanup.rake b/lib/tasks/finals_cleanup.rake index f79b67d1..db380908 100644 --- a/lib/tasks/finals_cleanup.rake +++ b/lib/tasks/finals_cleanup.rake @@ -1,25 +1,33 @@ # frozen_string_literal: true namespace :finals do - KEEP_TERMS = [ - { season: :summer, year: 2025 }, - { season: :fall, year: 2025 } - ].freeze - - desc "Delete all FinalExam and FinalsSchedule records except Summer 2025 and Fall 2025. Pass DRY_RUN=1 to preview." + desc "Delete FinalExam/FinalsSchedule records for PAST terms only. " \ + "Current and future terms are always protected. Safe by default (dry run); " \ + "pass CONFIRM=1 to apply. Optionally keep extra past terms with KEEP=\"season-year,...\"." task reset: :environment do - dry_run = ENV["DRY_RUN"].present? - puts dry_run ? "DRY RUN — no changes will be made." : "Deleting finals data..." + # Default to a dry run — only an explicit CONFIRM=1 actually deletes. + dry_run = ENV["CONFIRM"] != "1" + puts dry_run ? "DRY RUN — no changes will be made (pass CONFIRM=1 to apply)." : "Deleting finals data..." puts - protected_terms = KEEP_TERMS.filter_map do |t| - term = Term.find_by(season: t[:season], year: t[:year]) + # Always protect current and future terms so this never wipes live finals, + # regardless of when the task is run. + protected_terms = Term.current_and_future.to_a + + # Optionally protect additional past terms, e.g. KEEP="summer-2025,fall-2025". + ENV["KEEP"].to_s.split(",").map(&:strip).reject(&:blank?).each do |token| + season, year = token.split("-", 2) + term = Term.find_by(season: season&.downcase, year: year.to_i) if term - puts " Keeping: #{term.season.capitalize} #{term.year} (id=#{term.id})" + protected_terms << term else - puts " Warning: #{t[:season].to_s.capitalize} #{t[:year]} not found — nothing to protect" + puts " Warning: KEEP term '#{token}' not found — nothing to protect" end - term + end + + protected_terms.uniq! + protected_terms.each do |term| + puts " Keeping: #{term.season.to_s.capitalize} #{term.year} (id=#{term.id})" end puts @@ -55,7 +63,7 @@ namespace :finals do puts if dry_run puts "Would delete: #{total_exams} FinalExam(s) and #{total_schedules} FinalsSchedule(s) across #{terms_with_data.size} term(s)." - puts "Run without DRY_RUN=1 to apply." + puts "Run with CONFIRM=1 to apply." else puts "Deleted #{total_exams} FinalExam(s) and #{total_schedules} FinalsSchedule(s) across #{terms_with_data.size} term(s)." end diff --git a/spec/models/calendar_preference_spec.rb b/spec/models/calendar_preference_spec.rb new file mode 100644 index 00000000..f9063c1b --- /dev/null +++ b/spec/models/calendar_preference_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CalendarPreference, type: :model do + def create_user(email) + User.create!(email: email, password: "password123", first_name: "Test", last_name: "User") + end + + let(:user) { create_user("pref@wit.edu") } + + it "allows a single global preference for a user" do + expect(described_class.new(user: user, scope: :global)).to be_valid + end + + it "rejects a second global preference for the same user" do + described_class.create!(user: user, scope: :global) + + dupe = described_class.new(user: user, scope: :global) + + expect(dupe).not_to be_valid + expect(dupe.errors[:base].join).to match(/global preference already exists/) + end + + it "does not flag the existing record as a duplicate of itself" do + pref = described_class.create!(user: user, scope: :global) + + expect(pref).to be_valid + end + + it "allows a global preference for a different user" do + described_class.create!(user: user, scope: :global) + other = create_user("pref2@wit.edu") + + expect(described_class.new(user: other, scope: :global)).to be_valid + end +end diff --git a/spec/services/google_token_verifier_spec.rb b/spec/services/google_token_verifier_spec.rb new file mode 100644 index 00000000..81e2ff35 --- /dev/null +++ b/spec/services/google_token_verifier_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe GoogleTokenVerifier do + let(:client_id) { "wit-calendar.apps.googleusercontent.com" } + + def stub_tokeninfo(status:, body:) + response = instance_double(Faraday::Response, success?: status == 200, status: status, body: body) + connection = instance_double(Faraday::Connection, get: response) + allow_any_instance_of(described_class).to receive(:connection).and_return(connection) + allow_any_instance_of(described_class).to receive(:allowed_client_ids).and_return([ client_id ]) + end + + it "fails when no token is provided" do + expect(described_class.verify_access_token("").success?).to be(false) + end + + it "returns the normalized email for a token issued to our client" do + stub_tokeninfo(status: 200, body: { "aud" => client_id, "email" => "Student@WIT.edu", "email_verified" => "true" }) + + result = described_class.verify_access_token("valid-token") + + expect(result.success?).to be(true) + expect(result.email).to eq("student@wit.edu") + end + + it "rejects a token minted for a different OAuth client (audience mismatch)" do + stub_tokeninfo(status: 200, body: { "aud" => "some-other-app", "email" => "student@wit.edu" }) + + expect(described_class.verify_access_token("replayed-token").success?).to be(false) + end + + it "rejects a token Google did not accept" do + stub_tokeninfo(status: 401, body: { "error" => "invalid_token" }) + + expect(described_class.verify_access_token("bad-token").success?).to be(false) + end + + it "rejects a token with the right audience but no email" do + stub_tokeninfo(status: 200, body: { "aud" => client_id }) + + expect(described_class.verify_access_token("no-email").success?).to be(false) + end +end diff --git a/spec/services/json_web_token_service_spec.rb b/spec/services/json_web_token_service_spec.rb new file mode 100644 index 00000000..463cc0cf --- /dev/null +++ b/spec/services/json_web_token_service_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe JsonWebTokenService do + it "always sets an expiry claim" do + token = described_class.encode({ user_id: 1 }) + payload = JWT.decode(token, described_class::SECRET_KEY, true, algorithm: "HS256")[0] + + expect(payload["exp"]).to be_present + end + + it "raises when asked to encode without an expiry" do + expect { described_class.encode({ user_id: 1 }, nil) }.to raise_error(ArgumentError) + end + + it "does not mutate the caller's payload hash" do + payload = { user_id: 1 } + described_class.encode(payload) + + expect(payload).not_to have_key(:exp) + end + + it "round-trips a valid token" do + token = described_class.encode({ user_id: 42 }) + + expect(described_class.decode(token)[:user_id]).to eq(42) + end + + it "returns nil for an expired token" do + token = described_class.encode({ user_id: 42 }, 1.hour.ago) + + expect(described_class.decode(token)).to be_nil + end + + it "returns nil for a token signed with the wrong key" do + forged = JWT.encode({ user_id: 999, exp: 1.day.from_now.to_i }, "not-the-real-secret") + + expect(described_class.decode(forged)).to be_nil + end +end diff --git a/spec/services/meeting_times_ingest_service_spec.rb b/spec/services/meeting_times_ingest_service_spec.rb new file mode 100644 index 00000000..08715116 --- /dev/null +++ b/spec/services/meeting_times_ingest_service_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe MeetingTimesIngestService do + let(:term) do + Term.create!(uid: 202601, season: :spring, year: 2026, + start_date: Date.new(2026, 1, 15), end_date: Date.new(2026, 5, 1)) + end + + let(:course) do + Course.create!(term: term, crn: 12_345, course_number: 1000, section_number: "01", + subject: "COMP", title: "Intro to Computing", schedule_type: :lecture, + start_date: term.start_date, end_date: term.end_date) + end + + let(:raw) do + [ { + "startDate" => "2026-01-15", "endDate" => "2026-05-01", + "beginTime" => "0800", "endTime" => "0915", + "monday" => true, "building" => "IRA", "room" => "101" + } ] + end + + it "returns the ids of the meeting times it created" do + kept = described_class.call(course: course, raw_meeting_times: raw) + + expect(kept).to contain_exactly(course.meeting_times.sole.id) + end + + it "upserts stable ids on re-ingest rather than recreating rows" do + first_ids = described_class.call(course: course, raw_meeting_times: raw) + second_ids = described_class.call(course: course, raw_meeting_times: raw) + + expect(second_ids).to eq(first_ids) + expect(course.meeting_times.count).to eq(1) + end + + it "does not touch meeting times when reconciled against the kept ids" do + described_class.call(course: course, raw_meeting_times: raw) + original = course.meeting_times.sole + + kept = described_class.call(course: course, raw_meeting_times: raw) + course.meeting_times.where.not(id: kept).destroy_all if kept.any? + + expect(course.meeting_times.reload).to contain_exactly(original) + end +end