diff --git a/app/controllers/announcements_controller.rb b/app/controllers/announcements_controller.rb index d583f83abbe..6db79b7666a 100644 --- a/app/controllers/announcements_controller.rb +++ b/app/controllers/announcements_controller.rb @@ -26,6 +26,12 @@ def publicly_accessible? requesting_unread? || action_name.to_sym == :mark_as_read end + # The announcement bell polls this on every page. There are no global announcements on the preview + # instance, but a 403 on every poll would surface to the previewer as a stream of errors. + def preview_sandbox_accessible? + true + end + private def requesting_unread? diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c12193711ad..91f74c60463 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -14,6 +14,9 @@ class ApplicationController < ActionController::Base include ApplicationAbilityConcern include ApplicationAnnouncementsConcern include ApplicationPaginationConcern + # Last, so its `before_action` runs after the tenant is deduced and the user authenticated — both of + # which it reads. + include ApplicationPreviewSandboxConcern rescue_from AuthenticationError, with: :handle_authentication_error rescue_from IllegalStateError, with: :handle_illegal_state_error diff --git a/app/controllers/attachment_references_controller.rb b/app/controllers/attachment_references_controller.rb index e2f2bd95be0..f1f155af148 100644 --- a/app/controllers/attachment_references_controller.rb +++ b/app/controllers/attachment_references_controller.rb @@ -25,6 +25,14 @@ def show end end + protected + + # A previewed assessment's questions carry attachments (description images, template files) and its + # answers accept uploads, so both reading and writing an attachment are part of attempting one. + def preview_sandbox_accessible? + true + end + private def file_params diff --git a/app/controllers/concerns/application_preview_sandbox_concern.rb b/app/controllers/concerns/application_preview_sandbox_concern.rb new file mode 100644 index 00000000000..24976051d68 --- /dev/null +++ b/app/controllers/concerns/application_preview_sandbox_concern.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true +# Confines a marketplace previewer to the preview flow, at the request layer. +# +# On the preview instance a non-administrator may reach only what a controller +# explicitly claims. `preview_sandbox_accessible?` defaults to false and is overridden by the handful +# of actions the preview flow actually makes, mirroring how `publicly_accessible?` marks out the +# unauthenticated surface. +# +module ApplicationPreviewSandboxConcern + extend ActiveSupport::Concern + + included do + before_action :enforce_preview_sandbox_lock! + + # The root payload reports the lock to the courseless navigation shell, which has no course to read + # a flag off — the 404 page drops its "go back home" link on it. One fact, one source: the same + # predicate this concern enforces, rather than a second guess at who is confined. + helper_method :preview_sandbox_locked? + end + + protected + + # Whether this action belongs to the marketplace preview flow, and may therefore run for a + # non-administrator on the preview instance. Deny by default; override in the controllers that serve + # the flow. + # + # Devise is exempt wholesale rather than by action: sign-in, sign-up, password reset and + # confirmation all happen on the preview host, so a previewer who arrives without a session must be + # able to complete them or the sandbox is unreachable. Exempting the base class cannot miss one of + # the four subclasses. + # + # The root payload (locale, time zone, and the courses the user is in — here, only the container) is + # fetched on every page, the previewer's included. Singled out the same way `publicly_accessible?` + # singles out that one action. + # + # @return [Boolean] + def preview_sandbox_accessible? + devise_controller? || (controller_name == 'application' && action_name.to_sym == :index) + end + + # Whether `assessment_id` names an assessment a previewer was actually handed: the snapshot a listed + # listing currently serves. + # + # Deliberately no `can?` call. This runs before `load_and_authorize_resource :course`, and + # `Course::Controller#current_ability` memoizes on `current_course`; building the ability here would + # freeze a nil-course one for the rest of the request and deny the previewer everything downstream. + # + # @param [Integer, String, nil] assessment_id + # @return [Boolean] + def previewable_assessment?(assessment_id) + Course::Assessment::Marketplace::Listing.serving_assessment?(assessment_id) + end + + private + + def enforce_preview_sandbox_lock! + return unless preview_sandbox_locked? + return if preview_sandbox_accessible? + + raise CanCan::AccessDenied + end + + def preview_sandbox_locked? + return false if current_user&.administrator? + + Course::Assessment::Marketplace::PreviewContainerService.preview_instance?(current_tenant) + end +end diff --git a/app/controllers/course/assessment/assessments_controller.rb b/app/controllers/course/assessment/assessments_controller.rb index bc3d9207819..126bd7944fe 100644 --- a/app/controllers/course/assessment/assessments_controller.rb +++ b/app/controllers/course/assessment/assessments_controller.rb @@ -6,14 +6,14 @@ class Course::Assessment::AssessmentsController < Course::Assessment::Controller include Course::Assessment::Question::KoditsuQuestionConcern include Course::Assessment::KoditsuAssessmentInvitationConcern - before_action :load_submissions, only: [:show] + before_action :load_submissions, only: [:show], unless: :crumb_request? after_action :create_koditsu_invitation_job, only: [:update] after_action :create_fetch_koditsu_submissions_job, only: [:update] include Course::Assessment::MonitoringConcern include Course::Statistics::CountsConcern - before_action :load_question_duplication_data, only: [:show, :reorder] + before_action :load_question_duplication_data, only: [:show, :reorder], unless: :crumb_request? def index @assessments = @assessments.ordered_by_date_and_title.with_submissions_by(current_user) @@ -37,6 +37,7 @@ def index def show @assessment_time = @assessment.time_for(current_course_user) return render 'authenticate' unless can_access_assessment? + return render 'crumb' if crumb_request? @question_assessments = @assessment.question_assessments.with_question_actables @assessment_conditions = @assessment.assessment_conditions.includes({ conditional: :actable }) @@ -255,6 +256,15 @@ def plagiarism protected + # Both breadcrumb handles on a preview submission page fetch `show`, so the previewer needs it — + # but only for a title, and only for the snapshot they were handed. Not the page: a previewer is a + # `manager`, and `show` serves a manager the whole authoring surface. The index is not here either: + # it is the whole container, one row per published snapshot and per restored authoring copy, each + # with an Attempt button. + def preview_sandbox_accessible? + crumb_request? && previewable_assessment?(params[:id]) + end + def load_assessment_options return super if skip_tab_filter? @@ -263,6 +273,17 @@ def load_assessment_options private + # Whether this `show` is asking only for what a breadcrumb renders — the assessment's title and its + # tab's. Both crumb handles on any assessment page fetch `show`, and so does the assessment page + # itself; one endpoint serving both is what made the marketplace sandbox's crumb allowance a licence + # to read the authoring surface. Splitting them on the request rather than on the viewer keeps the + # payload the same for everyone and saves the page's ~50 queries on a fetch that renders two strings. + # + # @return [Boolean] + def crumb_request? + action_name.to_sym == :show && params[:crumb].present? + end + # Drives the view-only version badge on the container course's assessment index. Every published # snapshot keeps its original title and shares one tab, so without it an admin sees an # undifferentiated pile of identically-named assessments. Skipped everywhere else. diff --git a/app/controllers/course/assessment/marketplace/listings_controller.rb b/app/controllers/course/assessment/marketplace/listings_controller.rb index 85bea0e9102..721b11e10d3 100644 --- a/app/controllers/course/assessment/marketplace/listings_controller.rb +++ b/app/controllers/course/assessment/marketplace/listings_controller.rb @@ -34,7 +34,7 @@ def show includes(current_version: :assessment).find_by(id: params[:id]) raise CanCan::AccessDenied unless @listing - # The SNAPSHOT, never the authoring copy (design §4.2). + # The SNAPSHOT, never the authoring copy. @assessment = @listing.current_version&.assessment raise CanCan::AccessDenied unless @assessment @@ -44,6 +44,23 @@ def show end end + def launch_preview + ActsAsTenant.without_tenant do + @listing = Course::Assessment::Marketplace::Listing.published. + includes(current_version: :assessment).find_by(id: params[:id]) + raise CanCan::AccessDenied unless @listing + + # A preview rehearses the SNAPSHOT, never the authoring copy — the same row a duplicate would + # copy. Guarded here rather than in the service so a published listing with no + # snapshot is denied instead of crashing on a nil deep inside provisioning. + raise CanCan::AccessDenied unless @listing.current_version&.assessment + + authorize!(:preview_in_marketplace, @listing) + url = Course::Assessment::Marketplace::PreviewLaunchService.launch(@listing, current_user) + render json: { url: url } + end + end + private def authorize_access! diff --git a/app/controllers/course/assessment/marketplace/preview_submissions_controller.rb b/app/controllers/course/assessment/marketplace/preview_submissions_controller.rb new file mode 100644 index 00000000000..6d40c1f2fba --- /dev/null +++ b/app/controllers/course/assessment/marketplace/preview_submissions_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true +# Lets a marketplace previewer self-service reset THEIR OWN submission for an assessment inside the +# "Marketplace Preview Sandbox" container course, so they can relaunch "Try it hands-on" for a +# genuinely fresh attempt (Course::Assessment::Marketplace::PreviewLaunchService otherwise resumes +# an existing submission rather than resetting it). +# +# `update`, not `destroy`: the submission row is kept (same id) so the previewer stays on +# the same submission edit page and sees it come back blank. +# See Course::Assessment::Submission#reset_preview! for the clear-and-reset logic. +class Course::Assessment::Marketplace::PreviewSubmissionsController < Course::Assessment::Marketplace::Controller + before_action :ensure_preview_course! + before_action :load_assessment + + def update + submission = @assessment.submissions.find_by(creator: current_user) + return head :not_found unless submission + + authorize!(:reset_own_preview_submission, submission) + submission.reset_preview! + head :no_content + end + + protected + + # The one action written for previewers, and the only one they can reach that is not also an + # ordinary course action. The submission id never comes from the client (see above), so nothing here + # needs vetting beyond `ensure_preview_course!`. + def preview_sandbox_accessible? + true + end + + private + + # This self-service shortcut only ever exists inside the preview sandbox. A real course's + # teaching staff already have a vetted removal flow + # (Course::Assessment::Submission::SubmissionsController#delete/#delete_all); this action + # deliberately skips that flow's randomization/monitoring bookkeeping, since preview assessments + # are plain content-frozen copies with neither feature configured. + def ensure_preview_course! + raise CanCan::AccessDenied unless current_course.preview? + end + + def load_assessment + @assessment = current_course.assessments.find(params[:assessment_id]) + end +end diff --git a/app/controllers/course/assessment/submission/answer/controller.rb b/app/controllers/course/assessment/submission/answer/controller.rb index 322da753c3b..c52d7536a35 100644 --- a/app/controllers/course/assessment/submission/answer/controller.rb +++ b/app/controllers/course/assessment/submission/answer/controller.rb @@ -6,4 +6,14 @@ class Course::Assessment::Submission::Answer::Controller < \ singleton: true, through: :answer helper Course::Assessment::Submission::SubmissionsHelper.name.sub(/Helper$/, '') + + protected + + # Every action in this subtree (saving an answer, uploading a text-response file, adding a scribble, annotating code) + # is reached through `load_and_authorize_resource :submission`, which the preview ability confines to + # `creator_id: user.id`. Claimed once on the base rather than per subclass, so a new answer type + # does not silently break previews. + def preview_sandbox_accessible? + true + end end diff --git a/app/controllers/course/assessment/submission/submissions_controller.rb b/app/controllers/course/assessment/submission/submissions_controller.rb index bdae979fcc2..163dc0149b9 100644 --- a/app/controllers/course/assessment/submission/submissions_controller.rb +++ b/app/controllers/course/assessment/submission/submissions_controller.rb @@ -9,6 +9,21 @@ class Course::Assessment::Submission::SubmissionsController < # rubocop:disable include Course::Assessment::LiveFeedback::ThreadConcern include Course::Assessment::LiveFeedback::MessageConcern + # What a marketplace previewer may do here (see ApplicationPreviewSandboxConcern). Every one of + # these rides on `load_and_authorize_resource :submission` or an explicit `authorize!` against + # `@submission`, which the preview ability confines to `creator_id: user.id` — so allowing the action + # does not widen which submission it can reach. `create` is the exception and vets the assessment + # itself. + # + # Deliberately absent: every collection action. `publish_all`, `force_submit_all`, `unsubmit_all`, + # `download_all` and friends authorize against `@assessment` on verbs a manager's blanket + # `can :manage, Course::Assessment` already satisfies, so in a course every previewer shares they + # would reach every other previewer's submission. The live-feedback actions are absent too: + # `fetch_live_feedback_chat` reads a thread from a bare `answer_id` with no authorization at all. + PREVIEWER_ACTIONS = [ + :create, :edit, :update, :auto_grade, :reevaluate_answer, :generate_feedback, :reload_answer + ].to_set.freeze + before_action :authorize_assessment!, only: :create skip_authorize_resource :submission, only: [:edit, :update, :auto_grade] before_action :authorize_submission!, only: [:edit, :update] @@ -325,6 +340,18 @@ def delete_all render partial: 'jobs/submitted', locals: { job: job } end + protected + + def preview_sandbox_accessible? + return false unless PREVIEWER_ACTIONS.include?(action_name.to_sym) + # `create` backs the `attempt` route and mints the submission every other action here is scoped + # to, so it is the one that has to vet the assessment. Without this, guessing a container + # assessment id would hand the guesser a submission on it and legitimise everything downstream. + return true unless action_name.to_sym == :create + + previewable_assessment?(params[:assessment_id]) + end + private # When a grader opens a (submitted) submission, make sure every rubric-based answer has a v2 grading diff --git a/app/controllers/course/courses_controller.rb b/app/controllers/course/courses_controller.rb index 0719097bd80..a930e3b656d 100644 --- a/app/controllers/course/courses_controller.rb +++ b/app/controllers/course/courses_controller.rb @@ -45,6 +45,9 @@ def sidebar # # To re-enable, restore the original condition. @home_redirects_to_learn = false + + @preview_sandbox_admin = current_course.preview? && current_user&.administrator? + @preview_restricted = current_course.preview? && !@preview_sandbox_admin end protected @@ -53,6 +56,11 @@ def publicly_accessible? Set[:index, :show, :sidebar].include?(action_name.to_sym) end + # The layout payload is fetched on every course page, so the previewer's submission page needs it. + def preview_sandbox_accessible? + action_name.to_sym == :sidebar + end + private def course_params diff --git a/app/controllers/course/user_notifications_controller.rb b/app/controllers/course/user_notifications_controller.rb index de5b435259b..db8702def03 100644 --- a/app/controllers/course/user_notifications_controller.rb +++ b/app/controllers/course/user_notifications_controller.rb @@ -18,6 +18,12 @@ def publicly_accessible? Set[:fetch].include?(action_name.to_sym) end + # `PopupNotifier` polls `fetch` on every course page and dismisses through `mark_as_read`; both are + # scoped to the previewer's own notifications, `mark_as_read` by `load_and_authorize_resource`. + def preview_sandbox_accessible? + Set[:fetch, :mark_as_read].include?(action_name.to_sym) + end + private # Fetches the first unread popup `UserNotification` for the current course and returns JSON data diff --git a/app/controllers/csrf_token_controller.rb b/app/controllers/csrf_token_controller.rb index 58026dd4ba6..0d0d04edfb7 100644 --- a/app/controllers/csrf_token_controller.rb +++ b/app/controllers/csrf_token_controller.rb @@ -9,4 +9,9 @@ def csrf_token def publicly_accessible? true end + + # Every mutating request the previewer makes needs a token first. + def preview_sandbox_accessible? + true + end end diff --git a/app/controllers/jobs_controller.rb b/app/controllers/jobs_controller.rb index 6185ab2c61d..0edda8ee6bd 100644 --- a/app/controllers/jobs_controller.rb +++ b/app/controllers/jobs_controller.rb @@ -18,6 +18,11 @@ def publicly_accessible? true end + # Autograding a preview submission is a job, and the submission page polls it here. + def preview_sandbox_accessible? + true + end + private def load_job diff --git a/app/models/components/course/assessment_marketplace_ability_component.rb b/app/models/components/course/assessment_marketplace_ability_component.rb index 6dadf1c41f5..93e5b681255 100644 --- a/app/models/components/course/assessment_marketplace_ability_component.rb +++ b/app/models/components/course/assessment_marketplace_ability_component.rb @@ -34,7 +34,15 @@ def define_non_admin_course_permissions # super chain, so a `cannot` here takes precedence. This line is load-bearing. cannot :access_marketplace, Course, id: course.id end - restrict_preview_course_content if course.preview? + return unless course.preview? + + # Order matters: restrict_preview_course_reads's broad `:manage` cannot/can pair on + # Course::Assessment::Submission is defined BEFORE restrict_preview_course_content's narrower, + # verb-specific `:delete_submission`/`:reset_own_preview_submission` rules, so the latter — being + # defined LATER — keeps final precedence over those two specific verbs (CanCan evaluates rules in + # reverse-definition order: last defined wins). + restrict_preview_course_reads + restrict_preview_course_content end # Access is per-person, not per-current-course-role: anyone who is baseline-capable (manages/owns @@ -71,15 +79,41 @@ def allow_admins_publish_to_marketplace # and Course::CourseAbilityComponent in the `define_permissions` super chain: AbilityHost.components # is ordered by file path, and `_` (0x5F) sorts before `s` (0x73). Do not rename or move this file. def restrict_preview_course_content - assessments_in_course = { tab: { category: { course_id: course.id } } } cannot [:update, :destroy], Course::Assessment, assessments_in_course cannot :delete_all_submissions, Course::Assessment, assessments_in_course + # `:delete_submission` is revoked wholesale (not scoped to `creator_id`) because every + # previewer shares this one container course as a `manager`, and a manager's blanket + # `allow_manager_delete_assessment_submissions` would otherwise let them delete ANY + # previewer's submission, not just their own. Self-service reset of one's OWN submission is + # therefore a distinct, narrowly-scoped verb below, rather than a `creator_id`-scoped carve-out + # of `:delete_submission`. cannot :delete_submission, Course::Assessment::Submission, assessment: assessments_in_course + can :reset_own_preview_submission, Course::Assessment::Submission, + creator_id: user.id, assessment: assessments_in_course PREVIEW_FROZEN_QUESTION_TYPES.each do |question_class| cannot [:create, :update, :destroy], question_class end end + def assessments_in_course + { tab: { category: { course_id: course.id } } } + end + + # In a `preview` sandbox course, previewers are enrolled as `manager` of a container course SHARED + # by every other previewer in the whole instance (see PreviewContainerService). A manager's + # ordinary abilities would let them read/grade/publish ANY other previewer's submission, list every + # submission for any assessment in the sandbox, see the aggregate gradebook, and browse the full + # roster of everyone who has ever previewed anything here — none of which is any given previewer's + # business. Revoke it all, then carve back exactly their own submission. + def restrict_preview_course_reads + cannot :manage, Course::Assessment::Submission, assessment: assessments_in_course + can :manage, Course::Assessment::Submission, creator_id: user.id, assessment: assessments_in_course + cannot :view_all_submissions, Course::Assessment, assessments_in_course + cannot :read_gradebook, Course, id: course.id + cannot [:show_users, :manage_users], Course, id: course.id + cannot :manage, CourseUser + end + def allow_managers_access_marketplace can :access_marketplace, Course, id: course.id can :duplicate_from_marketplace, Course::Assessment::Marketplace::Listing, &:published? diff --git a/app/models/concerns/course/assessment/submission/cikgo_task_completion_concern.rb b/app/models/concerns/course/assessment/submission/cikgo_task_completion_concern.rb index 334356eed1b..90b1cf85933 100644 --- a/app/models/concerns/course/assessment/submission/cikgo_task_completion_concern.rb +++ b/app/models/concerns/course/assessment/submission/cikgo_task_completion_concern.rb @@ -25,6 +25,9 @@ def publish_task_completion end def should_publish_task_completion? + # A sandbox rehearsal must never report a completed task to the previewer's real external LMS. + return false if lesson_plan_item.course.preview? + lesson_plan_item.course.component_enabled?(Course::StoriesComponent) && creator_id_on_cikgo.present? && status.present? end diff --git a/app/models/concerns/course/assessment/submission/workflow_event_concern.rb b/app/models/concerns/course/assessment/submission/workflow_event_concern.rb index 51741c682ad..5d388175688 100644 --- a/app/models/concerns/course/assessment/submission/workflow_event_concern.rb +++ b/app/models/concerns/course/assessment/submission/workflow_event_concern.rb @@ -184,6 +184,9 @@ def delete_attempting_current_answers end def send_email_after_publishing(send_email) + # Prevents preview attempt submissions from sending emails. + return if assessment.course.preview? + return unless send_email && persisted? && !assessment.autograded? && submission_graded_email_enabled? && submission_graded_email_subscribed? diff --git a/app/models/course/assessment/marketplace/listing.rb b/app/models/course/assessment/marketplace/listing.rb index 21faa61fa97..435151ed5f8 100644 --- a/app/models/course/assessment/marketplace/listing.rb +++ b/app/models/course/assessment/marketplace/listing.rb @@ -42,6 +42,21 @@ def self.for_admin_index end end + # Whether `assessment_id` is the snapshot the marketplace currently serves for some listed listing; + # the only thing in the container course a previewer was ever handed a URL to (see `PreviewLaunchService`). + # Everything else there is a superseded snapshot, a restored authoring working copy, or the snapshot of a + # delisted listing, and container ids are guessable, so the preview sandbox lock vets the assessment + # with this rather than trusting a hidden index. + # + # @param [Integer] assessment_id + # @return [Boolean] + def self.serving_assessment?(assessment_id) + return false if assessment_id.blank? + + published.joins(:current_version). + exists?(course_assessment_marketplace_listing_versions: { assessment_id: assessment_id }) + end + # An orphaned listing lost its authoring copy (the origin assessment was deleted) but still # serves its last snapshot. Deliberately separate from `admin_state`, which is a display concern. # @return [Boolean] diff --git a/app/models/course/assessment/submission.rb b/app/models/course/assessment/submission.rb index cb5df4c80f6..f704043cc8d 100644 --- a/app/models/course/assessment/submission.rb +++ b/app/models/course/assessment/submission.rb @@ -9,6 +9,13 @@ class Course::Assessment::Submission < ApplicationRecord attr_accessor :has_unsubmitted_or_draft_answer + # The AutoGradingJob enqueued by *this* instance's finalising save, if it performed one. Assigned + # by `auto_grade_submission`'s after-commit block, which the `save` runs before the controller + # renders — so the response can hand the client a job url to poll (see the marketplace preview + # sandbox). Nil on every other request, since it lives only on the in-memory instance that made + # the transition. + attr_reader :auto_grading_job + acts_as_experience_points_record FORCE_SUBMIT_DELAY = 5.minutes @@ -217,6 +224,30 @@ def unsubmitting? !!@unsubmitting end + # Marketplace preview sandbox only: unconditionally clears every answer back to a fresh blank + # attempt and forces the submission's own state back to a pristine :attempting, regardless + # of what state the submission was in beforehand (including if it is already :attempting). + # + # This deliberately does not reuse WorkflowEventConcern#unsubmit/#recreate_current_answers. + # `recreate_current_answers` skip answers that are still `attempting?`, passes the old answer + # as `last_attempt` (which is not necessary), keeps audit history, and cannot be called when + # the submission is already :attempting. + def reset_preview! + transaction do + # Flip the submission's own state to :attempting FIRST (in memory only; not saved yet). Every + # new blank answer built below defaults to :attempting too, and Answer#validate_assessment_state + # requires `submission.attempting?` for an :attempting answer to be valid — reassigning after + # the loop (rather than before) would make each `new_answer.save!` fail validation, since the + # in-memory submission (shared with its answers via `inverse_of`) would still read as + # :submitted/:graded/:published at that point. + reset_preview_workflow_attributes + reset_preview_answers + answers.reload + + save! + end + end + def submission_view_blocked?(course_user) !attempting? && !published? && assessment.block_student_viewing_after_submitted? && course_user&.student? end @@ -346,13 +377,46 @@ def self.grade_summary(student_ids:, assessment_ids:) private + # See Submission#reset_preview!. + def reset_preview_workflow_attributes + self.workflow_state = 'attempting' + self.points_awarded = nil + self.draft_points_awarded = nil + self.awarded_at = nil + self.awarder = nil + self.submitted_at = nil + self.publisher = nil + self.published_at = nil + end + + # See Submission#reset_preview!. + # + # Unlike recreate_current_answers (unsubmit, real courses), the answers are DESTROYED here, not + # just flipped to non-current. + # + # Every answer goes, not just the current ones: answers already flipped to non-current by an + # earlier finalise/unsubmit round are the "Past Answers" trace the reset is meant to erase. + # The fresh blanks are built from `current_answers` (one per question) BEFORE anything is + # destroyed, and `stale_answers` is snapshotted first so the new rows are never in that list. + def reset_preview_answers + stale_answers = answers.to_a + + current_answers.each do |current_answer| + new_answer = current_answer.question.attempt(current_answer.submission) + new_answer.current_answer = true + new_answer.save! + end + + stale_answers.each(&:destroy!) + end + # Queues the submission for auto grading, after the submission has changed to the submitted state. def auto_grade_submission return unless saved_change_to_workflow_state? execute_after_commit do # Grade only ungraded answers regardless of state as we dont want to regrade graded/evaluated answers. - auto_grade!(only_ungraded: true) + @auto_grading_job = auto_grade!(only_ungraded: true) end end diff --git a/app/models/course/discussion/topic.rb b/app/models/course/discussion/topic.rb index 32a0fa0fefc..404a539abe8 100644 --- a/app/models/course/discussion/topic.rb +++ b/app/models/course/discussion/topic.rb @@ -81,8 +81,12 @@ def ensure_subscribed_by(user) raise e end + # No-op in the marketplace preview sandbox. + # + # Returns true, not false: the writers treat a falsey result as a failure and roll the enclosing + # post creation back (Course::Discussion::PostsConcern#update_topic_pending_status). def mark_as_pending - return true if pending_staff_reply + return true if course.preview? || pending_staff_reply self.pending_staff_reply = true save diff --git a/app/services/course/assessment/marketplace/preview_container_service.rb b/app/services/course/assessment/marketplace/preview_container_service.rb index 32a66a52060..360f5bcbd2c 100644 --- a/app/services/course/assessment/marketplace/preview_container_service.rb +++ b/app/services/course/assessment/marketplace/preview_container_service.rb @@ -23,6 +23,21 @@ def preview_instance find_preview_instance || create_preview_instance end + # Whether `instance` is the dedicated preview instance. The preview sandbox lock keys off this + # rather than off `Course#preview`, because it also has to confine a previewer on the courseless + # pages of this instance (`/courses`, `/role_requests`), where there is no course to read a flag + # from. The container is the only course here, so the instance is the wider of two circles + # that enclose the same content. + # + # Case-insensitive to match `find_preview_instance`: an instance that lookup resolves but this + # predicate rejects would silently leave the sandbox lock disengaged. + # + # @param [Instance, nil] instance + # @return [Boolean] + def preview_instance?(instance) + instance&.read_attribute(:host)&.downcase == PREVIEW_INSTANCE_HOST.downcase + end + # @return [Course] the container course in the preview instance. # # The flag alone is a unique key here: `index_courses_on_instance_id_one_preview` allows at most diff --git a/app/services/course/assessment/marketplace/preview_launch_service.rb b/app/services/course/assessment/marketplace/preview_launch_service.rb new file mode 100644 index 00000000000..fabd3bd1e71 --- /dev/null +++ b/app/services/course/assessment/marketplace/preview_launch_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true +# Attaches a previewer to the marketplace sandbox and returns the absolute attempt URL for the +# listing's served snapshot, on the preview host. Idempotent on (listing, user). +# +# Preconditions: the listing is published and has a `current_version`. `ListingsController +# #launch_preview` enforces both before calling — a listing with no snapshot has nothing to rehearse. +# +# This service performs NO authorization and must not. It runs with system privileges deliberately: the +# `preview` content-freeze (Course::AssessmentMarketplaceAbilityComponent) would otherwise block the very +# provisioning it depends on. The caller authorizes — see ListingsController#launch_preview. +class Course::Assessment::Marketplace::PreviewLaunchService + class << self + # Route helpers are not available on a plain class by default; delegate rather than include the + # whole url_helpers module, matching `CikgoTaskCompletionConcern#submission_url`'s approach. + delegate :course_assessment_attempt_path, to: 'Rails.application.routes.url_helpers' + + def launch(listing, user) + ActsAsTenant.without_tenant do + instance = Course::Assessment::Marketplace::PreviewContainerService.preview_instance + course = Course::Assessment::Marketplace::PreviewContainerService.container_course + snapshot = listing.current_version.assessment + ensure_enrolment(user, instance, course) + attempt_url(instance, course, snapshot) + end + end + + private + + def ensure_enrolment(user, instance, course) + ActsAsTenant.with_tenant(instance) do + InstanceUser.find_or_create_by!(user: user) { |instance_user| instance_user.role = :normal } + end + + course.course_users.find_or_create_by!(user: user) do |course_user| + course_user.name = user.name + course_user.role = :manager + course_user.creator = User.system + course_user.updater = User.system + end + end + + def attempt_url(instance, course, snapshot) + "#{instance.redirect_uri}#{course_assessment_attempt_path(course, snapshot)}" + end + end +end diff --git a/app/views/application/index.json.jbuilder b/app/views/application/index.json.jbuilder index 710bd18e660..cd434c71445 100644 --- a/app/views/application/index.json.jbuilder +++ b/app/views/application/index.json.jbuilder @@ -1,6 +1,10 @@ # frozen_string_literal: true json.locale I18n.locale json.timeZone ActiveSupport::TimeZone::MAPPING[user_time_zone] +# The courseless counterpart to the same field in `course/courses/sidebar.json.jbuilder`, for the +# navigation shell that renders without a course. A string comparison against the loaded tenant, so it +# costs nothing on an endpoint every page hits. +json.isPreviewRestricted preview_sandbox_locked? if user_signed_in? my_courses = Course.containing_user(current_user).ordered_by_start_at diff --git a/app/views/course/assessment/assessments/crumb.json.jbuilder b/app/views/course/assessment/assessments/crumb.json.jbuilder new file mode 100644 index 00000000000..a23a235dbc8 --- /dev/null +++ b/app/views/course/assessment/assessments/crumb.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true +# Everything a breadcrumb renders, and nothing else. The same partial `authenticate` and +# `blocked_by_monitor` open with, so a crumb request reads the same four fields whichever of the three +# `show` renders. +json.partial! 'assessment_list_data', assessment: @assessment, category: @category, tab: @tab, course: current_course diff --git a/app/views/course/courses/_sidebar_items.json.jbuilder b/app/views/course/courses/_sidebar_items.json.jbuilder index fddd58385f9..7c4cfbd33d1 100644 --- a/app/views/course/courses/_sidebar_items.json.jbuilder +++ b/app/views/course/courses/_sidebar_items.json.jbuilder @@ -3,7 +3,7 @@ json.array! items do |item| json.key item[:key] json.label item[:title] json.icon item[:icon] - if can_read + if link_items json.path item[:path] json.unread item[:unread] if item[:unread]&.nonzero? end diff --git a/app/views/course/courses/sidebar.json.jbuilder b/app/views/course/courses/sidebar.json.jbuilder index 1a31ba3f24f..be7d54a19c8 100644 --- a/app/views/course/courses/sidebar.json.jbuilder +++ b/app/views/course/courses/sidebar.json.jbuilder @@ -6,7 +6,7 @@ json.courseUserUrl url_to_user_or_course_user(current_course, current_course_use json.userName current_user&.name json.userId current_user&.id -if current_course_user.present? && can?(:read, current_course) +if current_course_user.present? && !@preview_sandbox_admin && can?(:read, current_course) json.courseUserName current_course_user.name json.courseUserRole current_course_user.role json.userAvatarUrl user_image(current_course_user.user) @@ -25,14 +25,16 @@ if current_course_user.present? && can?(:read, current_course) end json.isCourseEnrollable current_course.enrollable? +json.isPreview current_course.preview +json.isPreviewRestricted @preview_restricted -can_read = can?(:read, current_course) +link_items = can?(:read, current_course) && !@preview_restricted json.sidebar do - json.partial! 'sidebar_items', items: controller.sidebar_items(type: :normal), can_read: can_read + json.partial! 'sidebar_items', items: controller.sidebar_items(type: :normal), link_items: link_items end unless (admin_sidebar_items = controller.sidebar_items(type: :admin)).empty? json.adminSidebar do - json.partial! 'sidebar_items', items: admin_sidebar_items, can_read: can_read + json.partial! 'sidebar_items', items: admin_sidebar_items, link_items: link_items end end diff --git a/client/app/api/course/Assessment/Assessments.js b/client/app/api/course/Assessment/Assessments.js index 8091b8737f6..825ca8039bb 100644 --- a/client/app/api/course/Assessment/Assessments.js +++ b/client/app/api/course/Assessment/Assessments.js @@ -22,6 +22,19 @@ export default class AssessmentsAPI extends BaseCourseAPI { return this.client.get(`${this.#urlPrefix}/${assessmentId}`); } + /** + * Fetches only what a breadcrumb renders for an assessment: its title and its tab's. The full + * `fetch` is the assessment page's payload, and serving both from one request is what let a + * marketplace previewer read the authoring surface through the allowance their crumbs need. + * @param {number} assessmentId + * @returns An `AssessmentCrumbData` object + */ + fetchCrumb(assessmentId) { + return this.client.get(`${this.#urlPrefix}/${assessmentId}`, { + params: { crumb: true }, + }); + } + /** * Fetches the remaining unlock requirements for an assessment. * @param {number} assessmentId diff --git a/client/app/api/course/Marketplace.ts b/client/app/api/course/Marketplace.ts index 7a7b89f55f6..768a9928d6f 100644 --- a/client/app/api/course/Marketplace.ts +++ b/client/app/api/course/Marketplace.ts @@ -60,6 +60,16 @@ export default class MarketplaceAPI extends BaseCourseAPI { return this.client.get(`${this.#urlPrefix}/listings/${id}`); } + launchPreview(id: number): Promise> { + return this.client.post(`${this.#urlPrefix}/listings/${id}/launch_preview`); + } + + resetPreviewSubmission(assessmentId: number): Promise { + return this.client.patch( + `/courses/${this.courseId}/assessments/${assessmentId}/preview_submission`, + ); + } + fetchQuestion(listingId: number, questionId: number): Promise { return this.client.get( `${this.#urlPrefix}/listings/${listingId}/questions/${questionId}`, diff --git a/client/app/bundles/course/assessment/handles.ts b/client/app/bundles/course/assessment/handles.ts index e19b8386ed9..272e51417c2 100644 --- a/client/app/bundles/course/assessment/handles.ts +++ b/client/app/bundles/course/assessment/handles.ts @@ -3,7 +3,11 @@ import { getIdFromUnknown } from 'utilities'; import { CrumbPath, DataHandle } from 'lib/hooks/router/dynamicNest'; -import { fetchAssessment, fetchAssessments } from './operations/assessments'; +import { + fetchAssessment, + fetchAssessmentCrumb, + fetchAssessments, +} from './operations/assessments'; const getTabTitle = async ( categoryId?: number, @@ -23,7 +27,7 @@ const getTabTitle = async ( const getTabTitleFromAssessmentId = async ( assessmentId: number, ): Promise => { - const data = await fetchAssessment(assessmentId); + const data = await fetchAssessmentCrumb(assessmentId); return { activePath: data.tabUrl.split('&tab')[0], @@ -62,7 +66,7 @@ export const assessmentHandle: DataHandle = (match) => { return { getData: async (): Promise => { - const data = await fetchAssessment(assessmentId); + const data = await fetchAssessmentCrumb(assessmentId); return data.title; }, }; diff --git a/client/app/bundles/course/assessment/operations/assessments.ts b/client/app/bundles/course/assessment/operations/assessments.ts index b962f0e1f22..79c3a3555bd 100644 --- a/client/app/bundles/course/assessment/operations/assessments.ts +++ b/client/app/bundles/course/assessment/operations/assessments.ts @@ -2,6 +2,7 @@ import { AxiosError } from 'axios'; import { Operation } from 'store'; import { + AssessmentCrumbData, AssessmentDeleteResult, AssessmentsListData, AssessmentUnlockRequirements, @@ -35,6 +36,13 @@ export const fetchAssessment = async ( return response.data; }; +export const fetchAssessmentCrumb = async ( + id: number, +): Promise => { + const response = await CourseAPI.assessment.assessments.fetchCrumb(id); + return response.data; +}; + export const fetchAssessmentUnlockRequirements = async ( id: number, ): Promise => { diff --git a/client/app/bundles/course/container/Breadcrumbs/Breadcrumbs.tsx b/client/app/bundles/course/container/Breadcrumbs/Breadcrumbs.tsx index 7527e956bdf..ef9fc8cd363 100644 --- a/client/app/bundles/course/container/Breadcrumbs/Breadcrumbs.tsx +++ b/client/app/bundles/course/container/Breadcrumbs/Breadcrumbs.tsx @@ -12,10 +12,12 @@ interface BreadcrumbProps { in: CrumbData[]; className?: string; loading?: boolean; + /** Renders every crumb as inert text instead of a link — e.g. inside the marketplace preview sandbox, where nothing besides the current submission should be reachable. */ + disableLinks?: boolean; } const Breadcrumbs = (props: BreadcrumbProps): JSX.Element => { - const { in: crumbs } = props; + const { in: crumbs, disableLinks } = props; const { t } = useTranslation(); @@ -26,14 +28,14 @@ const Breadcrumbs = (props: BreadcrumbProps): JSX.Element => { forEachFlatCrumb(crumbs, (content, isLastCrumb, key) => { elements.push( - + {translatable(content.title) ? t(content.title) : content.title} , ); }); return elements; - }, [crumbs]); + }, [crumbs, disableLinks]); return (
diff --git a/client/app/bundles/course/container/Breadcrumbs/__test__/Breadcrumbs.test.tsx b/client/app/bundles/course/container/Breadcrumbs/__test__/Breadcrumbs.test.tsx new file mode 100644 index 00000000000..0b204dfd076 --- /dev/null +++ b/client/app/bundles/course/container/Breadcrumbs/__test__/Breadcrumbs.test.tsx @@ -0,0 +1,41 @@ +import { render } from 'test-utils'; + +import { CrumbData } from 'lib/hooks/router/dynamicNest'; + +import Breadcrumbs from '../Breadcrumbs'; + +const crumbs: CrumbData[] = [ + { + id: '1', + pathname: '/courses/1', + content: { url: '/courses/1', title: 'Course' }, + }, + { + id: '2', + pathname: '/courses/1/assessments', + content: { url: '/courses/1/assessments', title: 'Assessments' }, + }, + { + id: '3', + pathname: '/courses/1/assessments/2', + content: { title: 'Attempt' }, + }, +]; + +describe('Breadcrumbs', () => { + it('links every crumb except the last one', async () => { + const page = render(); + + expect((await page.findByText('Course')).closest('a')).not.toBeNull(); + expect((await page.findByText('Assessments')).closest('a')).not.toBeNull(); + expect((await page.findByText('Attempt')).closest('a')).toBeNull(); + }); + + it('renders every crumb as inert text when disableLinks is set', async () => { + const page = render(); + + expect((await page.findByText('Course')).closest('a')).toBeNull(); + expect((await page.findByText('Assessments')).closest('a')).toBeNull(); + expect((await page.findByText('Attempt')).closest('a')).toBeNull(); + }); +}); diff --git a/client/app/bundles/course/container/CourseContainer.tsx b/client/app/bundles/course/container/CourseContainer.tsx index 03c31b05368..ba4c7eb4448 100644 --- a/client/app/bundles/course/container/CourseContainer.tsx +++ b/client/app/bundles/course/container/CourseContainer.tsx @@ -17,6 +17,7 @@ import useTranslation, { translatable } from 'lib/hooks/useTranslation'; import Breadcrumbs from './Breadcrumbs'; import { loader, useCourseLoader } from './CourseLoader'; +import PreviewCourseBanner from './PreviewCourseBanner'; import Sidebar from './Sidebar'; const CourseContainer = (): JSX.Element => { @@ -63,11 +64,22 @@ const CourseContainer = (): JSX.Element => {
+ {/* + * Deliberately the first persistent, all-pages course banner: it lives + * here because the flag is on the layout payload, which is the + * only data available on every course page. + * + * The banner narrows itself further to submission pages only (see + * PreviewCourseBanner). This gate simply checks if user is in the sandbox. + */} + {data.isPreview && } +
diff --git a/client/app/bundles/course/container/PreviewCourseBanner.tsx b/client/app/bundles/course/container/PreviewCourseBanner.tsx new file mode 100644 index 00000000000..89b2b8a5135 --- /dev/null +++ b/client/app/bundles/course/container/PreviewCourseBanner.tsx @@ -0,0 +1,48 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert, Typography } from '@mui/material'; + +import ResetSubmissionButton from 'course/marketplace/components/ResetSubmissionButton'; +import { getAssessmentId, getSubmissionId } from 'lib/helpers/url-helpers'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + header: { + id: 'course.courses.PreviewCourseBanner.header', + defaultMessage: + 'You are in the Assessment Marketplace preview sandbox. Answers, submissions and grades stay here and never reach a real course.', + }, +}); + +// Mounted by CourseContainer on every page of the marketplace preview sandbox course, but only +// speaks on a submission page. +// +// Read straight off the URL (mirroring `BaseCourseAPI#courseId`) rather than route params: this +// banner is mounted alongside ``, not inside it, and preview pages are ordinary +// assessment/submission routes with no preview-only param to key off. CourseContainer subscribes to +// `useLocation`, so it re-renders this on every in-app navigation. +const PreviewCourseBanner: FC = () => { + const { t } = useTranslation(); + + const assessmentId = getAssessmentId(); + const submissionId = getSubmissionId(); + + if (!assessmentId || !submissionId) return null; + + return ( + + } + severity="info" + sx={{ alignItems: 'center' }} + > + {t(translations.header)} + + ); +}; + +export default PreviewCourseBanner; diff --git a/client/app/bundles/course/container/Sidebar/Sidebar.tsx b/client/app/bundles/course/container/Sidebar/Sidebar.tsx index b1dfc9d0788..42630221523 100644 --- a/client/app/bundles/course/container/Sidebar/Sidebar.tsx +++ b/client/app/bundles/course/container/Sidebar/Sidebar.tsx @@ -36,6 +36,13 @@ const Sidebar = forwardRef, SidebarProps>( const { t } = useTranslation(); + // Home is the one sidebar entry that does not come from the `sidebar` payload, so the de-link + // `sidebar.json.jbuilder` applies to every other item never reached it. Withholding its path below + // makes `SidebarItem` render it as the same grey inert row as its neighbours. + const homeUrl = data.homeRedirectsToLearn + ? `${data.courseUrl}/home` + : data.courseUrl; + return ( , SidebarProps>( {data.sidebar && (
{data.sidebar.map((item) => ( diff --git a/client/app/bundles/course/container/Sidebar/SidebarItem.tsx b/client/app/bundles/course/container/Sidebar/SidebarItem.tsx index 9efe99c77fb..97c27007dbf 100644 --- a/client/app/bundles/course/container/Sidebar/SidebarItem.tsx +++ b/client/app/bundles/course/container/Sidebar/SidebarItem.tsx @@ -87,7 +87,7 @@ const SidebarItem = (props: SidebarItemProps): JSX.Element => { ); }; -const HomeSidebarItem = (props: { to: string }): JSX.Element => { +const HomeSidebarItem = (props: { to?: string }): JSX.Element => { return ( ({ + ...jest.requireActual('lib/containers/AppContainer'), + useAppContext: (): HomeLayoutData => ({ + locale: 'en', + timeZone: 'Asia/Singapore', + }), +})); + +const data: CourseLayoutData = { + courseTitle: 'Marketplace Preview', + courseUrl: '/courses/1', + courseUserUrl: '/courses/1/users/1', + userName: 'Previewer', + userId: 1, + sidebar: [ + { + key: 'sidebar_assessments', + path: '/courses/1/assessments', + icon: 'assessment', + }, + ], +}; + +describe('Sidebar', () => { + it('links Home to the course', async () => { + const page = render(); + + expect((await page.findByText('Home')).closest('a')).toHaveAttribute( + 'href', + '/courses/1', + ); + }); + + it('links Home to the learn page when the course home redirects there', async () => { + const page = render( + , + ); + + expect((await page.findByText('Home')).closest('a')).toHaveAttribute( + 'href', + '/courses/1/home', + ); + }); + + it('renders Home as inert text for a restricted previewer', async () => { + const page = render( + , + ); + + expect((await page.findByText('Home')).closest('a')).toBeNull(); + }); +}); diff --git a/client/app/bundles/course/container/__tests__/PreviewCourseBanner.test.tsx b/client/app/bundles/course/container/__tests__/PreviewCourseBanner.test.tsx new file mode 100644 index 00000000000..dbfe2de7ec0 --- /dev/null +++ b/client/app/bundles/course/container/__tests__/PreviewCourseBanner.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from 'test-utils'; + +import PreviewCourseBanner from '../PreviewCourseBanner'; + +const SANDBOX_NOTICE = /marketplace preview sandbox/i; + +const goTo = (path: string): void => window.history.pushState({}, '', path); + +beforeEach(() => { + // Reset to the bare course-home URL `setup.js` establishes globally, so each test starts from + // "no submission id in the route" unless it navigates somewhere else itself. + goTo(`/courses/${global.courseId}`); +}); + +describe('PreviewCourseBanner', () => { + it('renders the marketplace preview sandbox notice inside a submission', async () => { + goTo(`/courses/${global.courseId}/assessments/5/submissions/9/edit`); + render(); + expect(await screen.findByText(SANDBOX_NOTICE)).toBeVisible(); + }); + + // Pinned separately from the label above: the notice used to claim the sandbox was "read-only" on + // the one page where a previewer types answers and submits them, so what it promises matters more + // than that it appeared. + it('promises that the work stays in the sandbox', async () => { + goTo(`/courses/${global.courseId}/assessments/5/submissions/9/edit`); + render(); + + expect(await screen.findByText(/never reach a real course/i)).toBeVisible(); + }); + + it('renders nothing on the course home page', () => { + render(); + expect(screen.queryByText(SANDBOX_NOTICE)).not.toBeInTheDocument(); + }); + + it('renders nothing on the assessments index', () => { + goTo(`/courses/${global.courseId}/assessments`); + render(); + expect(screen.queryByText(SANDBOX_NOTICE)).not.toBeInTheDocument(); + }); + + it("renders nothing on an assessment's own show page", () => { + goTo(`/courses/${global.courseId}/assessments/5`); + render(); + expect(screen.queryByText(SANDBOX_NOTICE)).not.toBeInTheDocument(); + }); +}); + +// Mirrors the exact conditional mounted in CourseContainer.tsx: +// `{data.isPreview && }`. CourseContainer itself pulls +// its data from a react-router loader, so it isn't worth a full-container +// mock scaffold just to prove this one boolean gate. +// +// The gate is `isPreview`, not the narrower `isPreviewRestricted` that de-links +// the breadcrumbs and sidebar: the banner states a fact about the course, so a +// system administrator sees it too (see Course::CoursesController#sidebar) — +// but only on the pages where it is true of what they are looking at, which the +// banner itself decides (see the suite above). +const Gated = ({ isPreview }: { isPreview?: boolean }): JSX.Element => ( + <> +
+ {isPreview && } + +); + +describe('the isPreview gate mounted in CourseContainer', () => { + beforeEach(() => { + goTo(`/courses/${global.courseId}/assessments/5/submissions/9/edit`); + }); + + it('shows the banner when isPreview is true', async () => { + render(); + expect(await screen.findByText(SANDBOX_NOTICE)).toBeVisible(); + }); + + it('does not show the banner when isPreview is false', async () => { + render(); + expect(await screen.findByTestId('marker')).toBeInTheDocument(); + expect(screen.queryByText(SANDBOX_NOTICE)).not.toBeInTheDocument(); + }); + + it('does not show the banner when isPreview is undefined', async () => { + render(); + expect(await screen.findByTestId('marker')).toBeInTheDocument(); + expect(screen.queryByText(SANDBOX_NOTICE)).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/marketplace/components/ResetSubmissionButton.tsx b/client/app/bundles/course/marketplace/components/ResetSubmissionButton.tsx new file mode 100644 index 00000000000..80a88657fd7 --- /dev/null +++ b/client/app/bundles/course/marketplace/components/ResetSubmissionButton.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import { RestartAlt } from '@mui/icons-material'; +import { Button } from '@mui/material'; + +import { fetchSubmission } from 'course/assessment/submission/actions'; +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import { useAppDispatch } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { resetPreviewSubmission } from '../operations'; +import translations from '../translations'; + +interface ResetSubmissionButtonProps { + assessmentId: string; + submissionId: string; +} + +// Rendered inside PreviewCourseBanner, which only mounts on a submission page inside the marketplace +// preview sandbox course and reads both ids off the URL there — so there is always something to +// reset by the time this renders. +const ResetSubmissionButton = ( + props: ResetSubmissionButtonProps, +): JSX.Element => { + const { assessmentId, submissionId } = props; + + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const confirm = async (): Promise => { + setSubmitting(true); + try { + await resetPreviewSubmission(Number(assessmentId)); + toast.success(t(translations.resetSubmissionSuccess)); + setOpen(false); + dispatch(fetchSubmission(submissionId)); + } catch { + toast.error(t(translations.resetSubmissionFailed)); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + + setOpen(false)} + open={open} + primaryColor="error" + primaryLabel={t(translations.resetSubmission)} + title={t(translations.resetSubmissionConfirmTitle)} + > + {t(translations.resetSubmissionConfirmBody)} + + + ); +}; + +export default ResetSubmissionButton; diff --git a/client/app/bundles/course/marketplace/components/TryItHandsOnButton.tsx b/client/app/bundles/course/marketplace/components/TryItHandsOnButton.tsx new file mode 100644 index 00000000000..856be0ac58a --- /dev/null +++ b/client/app/bundles/course/marketplace/components/TryItHandsOnButton.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import { OpenInNew } from '@mui/icons-material'; +import { Button } from '@mui/material'; + +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import { navigateTo } from 'lib/helpers/navigation'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { launchPreview } from '../operations'; +import translations from '../translations'; + +interface Props { + listingId: number; +} + +const TryItHandsOnButton = ({ listingId }: Props): JSX.Element => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const confirm = async (): Promise => { + // Opened synchronously with the click, before any `await` — a `window.open` issued after an + // `await` breaks the user-gesture chain and gets popup-blocked (most reliably in Safari). + const tab = window.open('', '_blank'); + setSubmitting(true); + try { + const { url } = await launchPreview(listingId); + if (tab) { + tab.location.href = url; + } else { + // The browser blocked the popup anyway; degrade to same-tab navigation rather than + // leaving a dead button. + navigateTo(url); + } + setOpen(false); + } catch { + // Never strand the user on a blank about:blank tab. + tab?.close(); + toast.error(t(translations.launchPreviewFailed)); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + + setOpen(false)} + open={open} + primaryColor="primary" + primaryLabel={t(translations.tryItHandsOn)} + title={t(translations.tryItHandsOnConfirmTitle)} + > + {t(translations.tryItHandsOnConfirmBody)} + + + ); +}; + +export default TryItHandsOnButton; diff --git a/client/app/bundles/course/marketplace/components/__test__/ResetSubmissionButton.test.tsx b/client/app/bundles/course/marketplace/components/__test__/ResetSubmissionButton.test.tsx new file mode 100644 index 00000000000..0a46a2223e5 --- /dev/null +++ b/client/app/bundles/course/marketplace/components/__test__/ResetSubmissionButton.test.tsx @@ -0,0 +1,96 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, screen, waitFor, within } from 'test-utils'; + +import CourseAPI from 'api/course'; +import { fetchSubmission } from 'course/assessment/submission/actions'; +import toast from 'lib/hooks/toast'; + +import ResetSubmissionButton from '../ResetSubmissionButton'; + +// The failure/success toasts are asserted directly (not rendered), so a plain jest.fn() mock is +// enough — mirrors ListingPreview's test. +jest.mock('lib/hooks/toast', () => ({ success: jest.fn(), error: jest.fn() })); + +// Mirrors the pattern in `SubmissionEditIndex/components/button/__test__/PublishButton.test.tsx`: +// the button only needs to dispatch the action, not run its real thunk body (which would hit +// `CourseAPI.assessment.submissions.edit` and pull in the whole submission reducer stack). +jest.mock('course/assessment/submission/actions', () => ({ + fetchSubmission: jest.fn(() => (): Promise => Promise.resolve()), +})); + +const mockFetchSubmission = fetchSubmission as jest.Mock; + +const RESET_SUBMISSION = 'Reset submission'; + +const goTo = (path: string): void => window.history.pushState({}, '', path); + +const renderButton = (): ReturnType => + render(); + +const mock = createMockAdapter(CourseAPI.marketplace.client); +beforeEach(() => { + mock.reset(); + jest.clearAllMocks(); + goTo(`/courses/${global.courseId}/assessments/5/submissions/9/edit`); +}); + +it('renders the button', async () => { + renderButton(); + expect( + await screen.findByRole('button', { name: RESET_SUBMISSION }), + ).toBeVisible(); +}); + +it('asks for confirmation before resetting', async () => { + renderButton(); + + fireEvent.click( + await screen.findByRole('button', { name: RESET_SUBMISSION }), + ); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText(/clears all your answers/)).toBeVisible(); +}); + +it('clears the submission, toasts success, does not navigate, and re-fetches the same submission in place', async () => { + const url = `/courses/${global.courseId}/assessments/5/preview_submission`; + mock.onPatch(url).reply(204); + + renderButton(); + + fireEvent.click( + await screen.findByRole('button', { name: RESET_SUBMISSION }), + ); + const dialog = await screen.findByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: RESET_SUBMISSION }), + ); + + await waitFor(() => expect(mock.history.patch).toHaveLength(1)); + expect(toast.success).toHaveBeenCalled(); + + // Still on the same submission edit page (no `navigate` call anywhere in this component + // anymore), and the now-blank submission is re-fetched in place. + expect(window.location.pathname).toBe( + `/courses/${global.courseId}/assessments/5/submissions/9/edit`, + ); + expect(mockFetchSubmission).toHaveBeenCalledWith('9'); +}); + +it('toasts an error and does not re-fetch when the reset fails', async () => { + const url = `/courses/${global.courseId}/assessments/5/preview_submission`; + mock.onPatch(url).reply(404); + + renderButton(); + + fireEvent.click( + await screen.findByRole('button', { name: RESET_SUBMISSION }), + ); + const dialog = await screen.findByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: RESET_SUBMISSION }), + ); + + await waitFor(() => expect(mock.history.patch).toHaveLength(1)); + expect(toast.error).toHaveBeenCalled(); + expect(mockFetchSubmission).not.toHaveBeenCalled(); +}); diff --git a/client/app/bundles/course/marketplace/operations.ts b/client/app/bundles/course/marketplace/operations.ts index 5b1e555600f..3aa284c4455 100644 --- a/client/app/bundles/course/marketplace/operations.ts +++ b/client/app/bundles/course/marketplace/operations.ts @@ -35,6 +35,19 @@ export const fetchListing = async (id: number): Promise => { return response.data as ListingPreviewData; }; +// Plain request, not `pollJob` — launch_preview is synchronous and returns the sandbox url +// directly, unlike `duplicate` which hands back a job to poll. +export const launchPreview = async (id: number): Promise<{ url: string }> => { + const response = await CourseAPI.marketplace.launchPreview(id); + return response.data as { url: string }; +}; + +export const resetPreviewSubmission = async ( + assessmentId: number, +): Promise => { + await CourseAPI.marketplace.resetPreviewSubmission(assessmentId); +}; + export const fetchQuestion = async ( listingId: number, questionId: number, diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx index 72913984483..a7f97a631c8 100644 --- a/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx @@ -1,10 +1,23 @@ import { createMockAdapter } from 'mocks/axiosMock'; -import { fireEvent, render, screen, waitFor } from 'test-utils'; +import { fireEvent, render, screen, waitFor, within } from 'test-utils'; import CourseAPI from 'api/course'; +import { navigateTo } from 'lib/helpers/navigation'; +import toast from 'lib/hooks/toast'; import ListingPreview from '../index'; +// jsdom seals the navigation surface (`window.location`, `location.href` and `location.assign` are +// all non-configurable, and a real `href` assignment is swallowed silently), so the popup-blocked +// branch is only assertable through this seam. See lib/helpers/navigation. +jest.mock('lib/helpers/navigation', () => ({ navigateTo: jest.fn() })); + +const mockNavigateTo = navigateTo as jest.Mock; + +// The failure toast is asserted directly (not rendered), so a plain jest.fn() mock is enough — +// unlike DuplicateConfirmation's toast, ours never carries a ReactNode payload. +jest.mock('lib/hooks/toast', () => ({ success: jest.fn(), error: jest.fn() })); + const mockNavigate = jest.fn(); // `TestApp` mounts the component directly inside a `MemoryRouter` with no matching @@ -37,9 +50,16 @@ jest.mock('../../../../container/CourseLoader', () => ({ // real fetchListing run. Auto-mocking operations makes fetchListing return undefined, and Preload's // `while` callback then does `undefined.then` → "Cannot read properties of undefined (reading 'then')". const mock = createMockAdapter(CourseAPI.marketplace.client); -beforeEach(() => mock.reset()); +beforeEach(() => { + mock.reset(); + jest.clearAllMocks(); +}); const LISTING_TITLE = 'Published, All Question Types'; +const DESCRIPTION_HTML = '

desc

'; +// Both the page's top-right action and the confirmation dialog's primary button carry this label, +// which is why the dialog click is always scoped with `within(dialog)`. +const TRY_IT_HANDS_ON = 'Try it hands-on'; it('renders the read-only assessment config', async () => { const url = `/courses/${global.courseId}/marketplace/listings/7`; @@ -164,7 +184,7 @@ it('carries from_tab into the per-question detail links', async () => { id: 70, title: LISTING_TITLE, destinationTabs: [], - description: '

desc

', + description: DESCRIPTION_HTML, gradingMode: 'manual', baseExp: 0, bonusExp: 0, @@ -201,7 +221,7 @@ it('navigates back to the marketplace carrying from_tab', async () => { id: 70, title: LISTING_TITLE, destinationTabs: [], - description: '

desc

', + description: DESCRIPTION_HTML, gradingMode: 'manual', baseExp: 0, bonusExp: 0, @@ -227,7 +247,7 @@ it('renders a back button to the marketplace index', async () => { id: 70, title: LISTING_TITLE, destinationTabs: [], - description: '

desc

', + description: DESCRIPTION_HTML, gradingMode: 'manual', baseExp: 0, bonusExp: 0, @@ -251,7 +271,7 @@ it('marks the page title as a preview', async () => { id: 70, title: LISTING_TITLE, destinationTabs: [], - description: '

desc

', + description: DESCRIPTION_HTML, gradingMode: 'manual', baseExp: 0, bonusExp: 0, @@ -269,3 +289,175 @@ it('marks the page title as a preview', async () => { // for the real assessment it mirrors. expect(screen.getByText('Preview')).toBeVisible(); }); + +it('opens the hands-on preview interstitial', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + destinationTabs: [], + description: DESCRIPTION_HTML, + gradingMode: 'manual', + baseExp: 0, + bonusExp: 0, + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: {}, + questions: [], + }); + + render(, { at: [url] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + fireEvent.click(screen.getByRole('button', { name: TRY_IT_HANDS_ON })); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText(/separate sandbox/)).toBeVisible(); +}); + +it('confirming posts to launch_preview and points the opened tab at the returned url', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + destinationTabs: [], + description: DESCRIPTION_HTML, + gradingMode: 'manual', + baseExp: 0, + bonusExp: 0, + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: {}, + questions: [], + }); + const previewUrl = + 'https://preview.sandbox.test/courses/9/assessments/3/attempt'; + // `TryItHandsOnButton` launches by the listing's own id (70), not the route param (7) — + // mirroring how `DuplicateConfirmation` on this same page keys its duplicate request off + // `listing.id`. + mock + .onPost( + `/courses/${global.courseId}/marketplace/listings/70/launch_preview`, + ) + .reply(200, { url: previewUrl }); + + // A fake tab standing in for the one `window.open('', '_blank')` returns synchronously with the + // click, before the launch_preview request resolves. + const tab = { location: {} as { href?: string }, close: jest.fn() }; + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue(tab as unknown as Window); + + render(, { at: [url] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + fireEvent.click(screen.getByRole('button', { name: TRY_IT_HANDS_ON })); // trigger + + const dialog = await screen.findByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: TRY_IT_HANDS_ON }), + ); // confirm — same label as the trigger, so scoped to the dialog + + // Opened synchronously with the click, not after the response lands. + expect(openSpy).toHaveBeenCalledWith('', '_blank'); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + await waitFor(() => expect(tab.location.href).toBe(previewUrl)); + // The marketplace tab stays put; only the blocked-popup fallback navigates it. + expect(mockNavigateTo).not.toHaveBeenCalled(); + + openSpy.mockRestore(); +}); + +it('navigates in place when the browser blocks the popup', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + destinationTabs: [], + description: DESCRIPTION_HTML, + gradingMode: 'manual', + baseExp: 0, + bonusExp: 0, + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: {}, + questions: [], + }); + const previewUrl = + 'https://preview.sandbox.test/courses/9/assessments/3/attempt'; + mock + .onPost( + `/courses/${global.courseId}/marketplace/listings/70/launch_preview`, + ) + .reply(200, { url: previewUrl }); + + // A blocked popup: `window.open` returns null even though the call rode the click's user gesture. + const openSpy = jest.spyOn(window, 'open').mockReturnValue(null); + + render(, { at: [url] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + fireEvent.click(screen.getByRole('button', { name: TRY_IT_HANDS_ON })); + + const dialog = await screen.findByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: TRY_IT_HANDS_ON }), + ); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + + // Degrades to same-tab navigation rather than leaving a dead button, and stays silent — the + // launch itself succeeded, so an error toast would be a lie. + await waitFor(() => expect(mockNavigateTo).toHaveBeenCalledWith(previewUrl)); + expect(toast.error).not.toHaveBeenCalled(); + + openSpy.mockRestore(); +}); + +it('closes the tab and toasts an error when the launch fails', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + destinationTabs: [], + description: DESCRIPTION_HTML, + gradingMode: 'manual', + baseExp: 0, + bonusExp: 0, + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: {}, + questions: [], + }); + mock + .onPost( + `/courses/${global.courseId}/marketplace/listings/70/launch_preview`, + ) + .reply(500); + + const tab = { location: {} as { href?: string }, close: jest.fn() }; + const openSpy = jest + .spyOn(window, 'open') + .mockReturnValue(tab as unknown as Window); + + render(, { at: [url] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + fireEvent.click(screen.getByRole('button', { name: TRY_IT_HANDS_ON })); + + const dialog = await screen.findByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: TRY_IT_HANDS_ON }), + ); + + // Never strand the user on a blank about:blank tab. + await waitFor(() => expect(tab.close).toHaveBeenCalled()); + expect(toast.error).toHaveBeenCalled(); + + openSpy.mockRestore(); +}); diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx index 890cad61b50..05b4e439e21 100644 --- a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx @@ -13,6 +13,7 @@ import Preload from 'lib/components/wrappers/Preload'; import useTranslation from 'lib/hooks/useTranslation'; import DuplicateConfirmation from '../../components/DuplicateConfirmation'; +import TryItHandsOnButton from '../../components/TryItHandsOnButton'; import { readFromTab, withFromTab } from '../../fromTab'; import { fetchListing } from '../../operations'; import translations from '../../translations'; @@ -38,14 +39,17 @@ const ListingPreview = (): JSX.Element => { {(listing): JSX.Element => ( setDuplicating(true)} - startIcon={} - variant="contained" - > - {t(translations.duplicateAssessment)} - + <> + + + } backTo={withFromTab(`${courseUrl}/marketplace`, fromTab)} className="space-y-5" diff --git a/client/app/bundles/course/marketplace/translations.ts b/client/app/bundles/course/marketplace/translations.ts index 646dde63805..8d543d7f16f 100644 --- a/client/app/bundles/course/marketplace/translations.ts +++ b/client/app/bundles/course/marketplace/translations.ts @@ -102,6 +102,44 @@ export default defineMessages({ id: 'course.marketplace.duplicateAssessment', defaultMessage: 'Duplicate Assessment', }, + tryItHandsOn: { + id: 'course.marketplace.tryItHandsOn', + defaultMessage: 'Try it hands-on', + }, + tryItHandsOnConfirmTitle: { + id: 'course.marketplace.tryItHandsOnConfirmTitle', + defaultMessage: 'Try it hands-on?', + }, + tryItHandsOnConfirmBody: { + id: 'course.marketplace.tryItHandsOnConfirmBody', + defaultMessage: + 'This opens a hands-on preview in a separate sandbox, in a new tab. You may be briefly redirected to sign in. Your own course is not affected.', + }, + launchPreviewFailed: { + id: 'course.marketplace.launchPreviewFailed', + defaultMessage: 'Could not launch the preview.', + }, + resetSubmission: { + id: 'course.marketplace.resetSubmission', + defaultMessage: 'Reset submission', + }, + resetSubmissionConfirmTitle: { + id: 'course.marketplace.resetSubmissionConfirmTitle', + defaultMessage: 'Reset your submission?', + }, + resetSubmissionConfirmBody: { + id: 'course.marketplace.resetSubmissionConfirmBody', + defaultMessage: + 'This clears all your answers for this assessment in the preview sandbox back to a blank state. You’ll stay on this page and can start answering again right away.', + }, + resetSubmissionSuccess: { + id: 'course.marketplace.resetSubmissionSuccess', + defaultMessage: 'Submission reset.', + }, + resetSubmissionFailed: { + id: 'course.marketplace.resetSubmissionFailed', + defaultMessage: 'Could not reset the submission.', + }, viewDetails: { id: 'course.marketplace.viewDetails', defaultMessage: 'View question details', diff --git a/client/app/lib/helpers/navigation.ts b/client/app/lib/helpers/navigation.ts new file mode 100644 index 00000000000..ccec5fb1516 --- /dev/null +++ b/client/app/lib/helpers/navigation.ts @@ -0,0 +1,17 @@ +/** + * Performs a full-page navigation, leaving the SPA. + * + * Exists as a seam, not an abstraction: jsdom seals the whole navigation surface — `window.location`, + * `location.href` and `location.assign` are all non-configurable, and a real `href` assignment is + * swallowed with a virtual-console warning instead of throwing. So a component that navigates + * inline has no assertable behaviour, and a test of that branch passes whether or not the branch + * still exists. Routing through this module gives tests something to `jest.mock`. + * + * Use it where a full page load is genuinely wanted (a cross-instance url, a Rails-rendered page). + * Within the SPA, prefer react-router's `useNavigate`. + * + * @param url The url to navigate to. + */ +export const navigateTo = (url: string): void => { + window.location.href = url; +}; diff --git a/client/app/types/course/assessment/assessments.ts b/client/app/types/course/assessment/assessments.ts index 669553a9522..2464d49a275 100644 --- a/client/app/types/course/assessment/assessments.ts +++ b/client/app/types/course/assessment/assessments.ts @@ -214,6 +214,18 @@ export interface AssessmentData extends AssessmentActionsData { generateQuestionUrls?: GenerateQuestionBuilderData[]; } +/** + * What `show` renders for a breadcrumb request, and the subset every one of the three full variants + * below opens with — so a crumb consumer reads the same fields whichever the backend falls through to + * (view-password locked, monitor-blocked, or accessible). + */ +export interface AssessmentCrumbData { + id: number; + title: string; + tabTitle: string; + tabUrl: string; +} + export interface UnauthenticatedAssessmentData { id: number; title: string; diff --git a/client/app/types/course/courses.ts b/client/app/types/course/courses.ts index eee97652e39..67fde120b12 100644 --- a/client/app/types/course/courses.ts +++ b/client/app/types/course/courses.ts @@ -113,6 +113,10 @@ export interface CourseLayoutData { courseUserRole?: CourseUserRole; userAvatarUrl?: string; homeRedirectsToLearn?: boolean; + isPreview?: boolean; + // Whether the marketplace sandbox's read-only lock applies to THIS viewer. False for a system + // administrator, who curates the container from inside it, while `isPreview` stays true for them. + isPreviewRestricted?: boolean; sidebar?: SidebarItemData[]; adminSidebar?: SidebarItemData[]; manageEmailSubscriptionUrl?: string; diff --git a/client/locales/en.json b/client/locales/en.json index aa30c20c106..7d5e5e4b589 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -6218,6 +6218,21 @@ "course.marketplace.noPreviewImage": { "defaultMessage": "The background image for this question cannot be previewed here." }, + "course.marketplace.resetSubmission": { + "defaultMessage": "Reset submission" + }, + "course.marketplace.resetSubmissionConfirmTitle": { + "defaultMessage": "Reset your submission?" + }, + "course.marketplace.resetSubmissionConfirmBody": { + "defaultMessage": "This clears all your answers for this assessment in the preview sandbox back to a blank state. You’ll stay on this page and can start answering again right away." + }, + "course.marketplace.resetSubmissionSuccess": { + "defaultMessage": "Submission reset." + }, + "course.marketplace.resetSubmissionFailed": { + "defaultMessage": "Could not reset the submission." + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "Download has failed. Please try again later." }, diff --git a/client/locales/ko.json b/client/locales/ko.json index 10e11ce6b7b..067c407c618 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -6182,6 +6182,21 @@ "course.marketplace.noPreviewImage": { "defaultMessage": "이 문항의 배경 이미지는 여기에서 미리 볼 수 없습니다." }, + "course.marketplace.resetSubmission": { + "defaultMessage": "제출 재설정" + }, + "course.marketplace.resetSubmissionConfirmTitle": { + "defaultMessage": "제출을 재설정하시겠습니까?" + }, + "course.marketplace.resetSubmissionConfirmBody": { + "defaultMessage": "미리보기 샌드박스에서 이 평가에 작성한 답안이 모두 빈 상태로 초기화됩니다. 이 페이지에 그대로 머물면서 바로 다시 답안을 작성할 수 있습니다." + }, + "course.marketplace.resetSubmissionSuccess": { + "defaultMessage": "제출이 재설정되었습니다." + }, + "course.marketplace.resetSubmissionFailed": { + "defaultMessage": "제출을 재설정할 수 없습니다." + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "다운로드에 실패했습니다. 나중에 다시 시도하세요." }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 7649ff1bab5..8ab4a19970f 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -6176,6 +6176,21 @@ "course.marketplace.noPreviewImage": { "defaultMessage": "此题目的背景图片无法在此预览。" }, + "course.marketplace.resetSubmission": { + "defaultMessage": "重置提交" + }, + "course.marketplace.resetSubmissionConfirmTitle": { + "defaultMessage": "要重置你的提交吗?" + }, + "course.marketplace.resetSubmissionConfirmBody": { + "defaultMessage": "这会将你在预览沙盒中对此评估的所有答案清空为初始状态。你会留在本页面,可以立即重新开始作答。" + }, + "course.marketplace.resetSubmissionSuccess": { + "defaultMessage": "提交已重置。" + }, + "course.marketplace.resetSubmissionFailed": { + "defaultMessage": "无法重置提交。" + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "下载失败。请稍后再试。" }, diff --git a/config/routes.rb b/config/routes.rb index e527cbc2102..5659f2f2ecb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -303,6 +303,7 @@ resource :marketplace_adoption, only: [] do post 'apply_latest_version' => 'marketplace_adoptions#apply_latest_version' end + resource :preview_submission, only: [:update], controller: 'marketplace/preview_submissions' namespace :question do resources :multiple_responses, only: [:new, :create, :edit, :update, :destroy] do @@ -637,6 +638,7 @@ get 'marketplace' => 'listings#index', as: :marketplace resources :listings, only: [:show], path: 'marketplace/listings' do post 'duplicate', on: :collection + post 'launch_preview', on: :member resources :questions, only: [:show] end end diff --git a/spec/controllers/application_root_payload_spec.rb b/spec/controllers/application_root_payload_spec.rb new file mode 100644 index 00000000000..0de3c573e27 --- /dev/null +++ b/spec/controllers/application_root_payload_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true +require 'rails_helper' + +# The root payload is fetched on every page and is the only data the courseless navigation shell has, +# so it is where the preview sandbox lock reaches the surfaces that cannot read it off a course — the +# 404 page, which drops its "go back home" link when the flag is set. +# +# A separate file from `application_controller_spec.rb`: that spec defines an anonymous +# `controller do ... end`, which cannot render the real `application/index.json.jbuilder`. +# +# Drives the REAL preview instance rather than a stand-in `create(:course, preview: true)`, the same +# way `spec/controllers/course/assessment/marketplace/preview_sandbox_lock_spec.rb` does: the +# predicate keys off the instance precisely because a courseless page has no course to read a flag +# from, so a stand-in course elsewhere would not trip it. +RSpec.describe ApplicationController, 'root payload', type: :controller do + render_views + + subject(:payload) do + get :index, format: :json + JSON.parse(response.body) + end + + let(:preview_instance) { Course::Assessment::Marketplace::PreviewContainerService.preview_instance } + let(:previewer) { ActsAsTenant.with_tenant(Instance.default) { create(:user) } } + let(:listing) do + ActsAsTenant.with_tenant(Instance.default) do + source = create(:assessment, course: create(:course)) + Course::Assessment::Marketplace::PublishService.publish(source, source.course.creator) + end + end + + before { Course::Assessment::Marketplace::PreviewLaunchService.launch(listing, previewer) } + + context 'when on the preview instance' do + with_tenant(:preview_instance) do + context 'when the viewer is a previewer' do + before { controller_sign_in(controller, previewer) } + + it 'reports the sandbox lock' do + expect(payload['isPreviewRestricted']).to be(true) + end + end + + # The lock is per-viewer: a system administrator curates the container from inside it, so the + # navigation shell stays whole for them. Mirrors the exemption in `preview_sandbox_locked?`. + context 'when the viewer is a system administrator' do + let(:administrator) { ActsAsTenant.with_tenant(Instance.default) { create(:administrator) } } + + before { controller_sign_in(controller, administrator) } + + it 'does not report the sandbox lock' do + expect(payload['isPreviewRestricted']).to be(false) + end + end + end + end + + # Same previewer, ordinary instance: the lock is a property of where they are, not of who they are. + context 'when outside the preview instance' do + let(:instance) { Instance.default } + + with_tenant(:instance) do + before { controller_sign_in(controller, previewer) } + + it 'does not report the sandbox lock' do + expect(payload['isPreviewRestricted']).to be(false) + end + end + end +end diff --git a/spec/controllers/course/assessment/assessments_controller_spec.rb b/spec/controllers/course/assessment/assessments_controller_spec.rb index 10129988692..11bcd998241 100644 --- a/spec/controllers/course/assessment/assessments_controller_spec.rb +++ b/spec/controllers/course/assessment/assessments_controller_spec.rb @@ -133,6 +133,34 @@ end end + describe '#show' do + render_views + + let(:assessment) { create(:assessment, :published_with_all_question_types, course: course) } + + # A breadcrumb needs a title and a tab; the assessment page needs everything. They shared one + # endpoint, which is what let a marketplace previewer reach the authoring surface through the + # allowance their breadcrumb needs (see ApplicationPreviewSandboxConcern). The flag splits them. + context 'when the request asks only for breadcrumb data' do + before { get :show, as: :json, params: { course_id: course, id: assessment, crumb: true } } + + it 'renders the title and the tab, and nothing else' do + expect(response.parsed_body.keys).to contain_exactly('id', 'title', 'tabTitle', 'tabUrl') + end + + it 'does not assemble the assessment page' do + expect(assigns(:submissions)).to be_nil + expect(assigns(:question_duplication_dropdown_data)).to be_nil + expect(assigns(:questions)).to be_nil + end + end + + it 'renders the whole assessment page without the flag' do + get :show, as: :json, params: { course_id: course, id: assessment } + expect(response.parsed_body['permissions']).to be_present + end + end + describe '#destroy' do subject { delete :destroy, params: { course_id: course, id: immutable_assessment } } diff --git a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb index b9c9e9b9b36..a049e3b7d97 100644 --- a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb @@ -234,7 +234,7 @@ before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) } - # Published through the real service: `#show` renders the container SNAPSHOT (design §4.2), + # Published through the real service: `#show` renders the container SNAPSHOT, # so the question must exist on the snapshot, not just the authoring copy. let!(:listing) do assessment = create(:assessment, course: create(:course)) @@ -246,6 +246,9 @@ get :show, params: { course_id: course, id: listing.id, format: :json } expect(response).to have_http_status(:ok) body = response.parsed_body + # Must be the listing's own id, not the assessment's — TryItHandsOnButton and + # DuplicateConfirmation POST back with this id to endpoints keyed on `Listing#id`. + expect(body['id']).to eq(listing.id) expect(body).to include('title', 'gradingMode', 'showMcqMrqSolution', 'showRubricToStudents', 'gradedTestCases') # The listing preview reports the human-readable question type, matching the per-question chips. readable_type = I18n.t('course.assessment.question.multiple_responses.question_type.multiple_choice') @@ -319,6 +322,52 @@ expect(row['questionCount']).to eq(1) end end + + describe 'POST #launch_preview' do + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) } + + let!(:listing) { create(:course_assessment_marketplace_listing, :versioned, published: true) } + + it 'provisions the preview and returns the attempt url' do + post :launch_preview, params: { course_id: course, id: listing.id, format: :json } + expect(response).to have_http_status(:ok) + expect(response.parsed_body['url']).to be_present + end + + context 'when the manager is not on the allow-list' do + # Outer `before` above already granted the rule; wipe it so the marketplace gate itself + # (not the base :read gate, which a manager already passes) is what denies this request. + before { Course::Assessment::Marketplace::AllowlistRule.delete_all } + + it 'denies access' do + expect do + post :launch_preview, params: { course_id: course, id: listing.id, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end + + context 'when the listing is unpublished' do + let!(:listing) { create(:course_assessment_marketplace_listing, published: false) } + + it 'denies access' do + expect do + post :launch_preview, params: { course_id: course, id: listing.id, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end + + # A preview attempts the snapshot, so a published listing that has none has nothing to + # rehearse. Denied at the controller rather than left to fail on a nil inside provisioning. + context 'when the listing has no snapshot' do + let!(:listing) { create(:course_assessment_marketplace_listing, published: true) } + + it 'denies access' do + expect do + post :launch_preview, params: { course_id: course, id: listing.id, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end + end end # Cross-instance: a listing published in another instance is visible. diff --git a/spec/controllers/course/assessment/marketplace/preview_sandbox_lock_spec.rb b/spec/controllers/course/assessment/marketplace/preview_sandbox_lock_spec.rb new file mode 100644 index 00000000000..0fc2e2742cb --- /dev/null +++ b/spec/controllers/course/assessment/marketplace/preview_sandbox_lock_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true +require 'rails_helper' + +# The lock is one request-layer allow-list spanning many controllers, so it is specced in one file +# rather than smeared across each controller's own spec. What matters is the boundary itself: reading +# the allowed and denied surfaces side by side is the only way to see what a previewer can still +# reach, and a per-controller spec cannot say "and nothing else". +# +# This spec drives the REAL preview instance and container course, unlike the rest of the marketplace +# suite, which stands in a throwaway `create(:course, preview: true)`. It has to: the lock keys off +# the instance (a previewer must be confined on courseless pages too, where there is no course to +# read a flag from), so a stand-in course on some other instance would not trip it. Same call the +# production path makes, so the setup is `PublishService` + `PreviewLaunchService` rather than +# hand-built fixtures. +RSpec.shared_context 'marketplace preview sandbox' do + let(:preview_instance) { Course::Assessment::Marketplace::PreviewContainerService.preview_instance } + let(:container) do + ActsAsTenant.without_tenant { Course::Assessment::Marketplace::PreviewContainerService.container_course } + end + let(:previewer) { ActsAsTenant.with_tenant(Instance.default) { create(:user) } } + + # Published from an ordinary course on the default instance, exactly as a real listing is: the + # snapshot `PublishService` puts in the container is what a previewer is handed. + let(:listing) do + ActsAsTenant.with_tenant(Instance.default) do + source = create(:assessment, course: create(:course)) + Course::Assessment::Marketplace::PublishService.publish(source, source.course.creator) + end + end + let(:snapshot) { ActsAsTenant.without_tenant { listing.current_version.assessment } } + + # A container assessment no published listing serves — a superseded snapshot, a restored authoring + # working copy, or the snapshot of a delisted listing all look like this. Container ids are + # guessable, so reaching one must not depend on the index being hidden. + let(:unserved_assessment) do + ActsAsTenant.with_tenant(preview_instance) { create(:assessment, course: container) } + end + + before { Course::Assessment::Marketplace::PreviewLaunchService.launch(listing, previewer) } +end + +RSpec.describe Course::CoursesController, type: :controller do + include_context 'marketplace preview sandbox' + + with_tenant(:preview_instance) do + before { controller_sign_in(controller, previewer) } + + it 'denies the sandbox course home page' do + expect { get :show, params: { id: container, format: :json } }. + to raise_exception(CanCan::AccessDenied) + end + + it 'denies the instance course index' do + expect { get :index, format: :json }.to raise_exception(CanCan::AccessDenied) + end + + # The layout payload is fetched on every course page, so denying it would take the submission + # page down with it. + it 'allows the sidebar' do + get :sidebar, params: { id: container, format: :json } + expect(response).to have_http_status(:success) + end + end +end + +RSpec.describe Course::AnnouncementsController, type: :controller do + include_context 'marketplace preview sandbox' + + with_tenant(:preview_instance) do + before { controller_sign_in(controller, previewer) } + + # Stands in for every component page in the sandbox: lesson plan, materials, forums, surveys, + # videos, comments, statistics. Announcements specifically, because it is one the ability + # component never mentions — a previewer's `manager` role carries it outright, so it fails without + # the lock. (The users page would pass either way: `cannot [:show_users, :manage_users]` already + # covers the roster, and asserting it here would prove nothing about this gate.) + it 'denies a component page nothing else revokes' do + expect { get :index, params: { course_id: container, format: :json } }. + to raise_exception(CanCan::AccessDenied) + end + end +end + +RSpec.describe Course::Assessment::AssessmentsController, type: :controller do + include_context 'marketplace preview sandbox' + + with_tenant(:preview_instance) do + before { controller_sign_in(controller, previewer) } + + it 'denies the container assessment index' do + expect { get :index, params: { course_id: container, format: :json } }. + to raise_exception(CanCan::AccessDenied) + end + + # The assessment page is not part of the preview flow, and for a `manager` — which every previewer + # is — `show` serves the whole authoring surface: `canManage`, the question edit urls, the + # new-question and generate-question urls, graded test case visibility. Only the breadcrumb needs + # this endpoint, and only for a title. + it 'denies the snapshot page' do + expect { get :show, params: { course_id: container, id: snapshot, format: :json } }. + to raise_exception(CanCan::AccessDenied) + end + + # Both breadcrumb handles on the submission page fetch this endpoint, so it is load-bearing. + it 'allows a breadcrumb request for the snapshot a published listing serves' do + get :show, params: { course_id: container, id: snapshot, crumb: true, format: :json } + expect(response).to have_http_status(:success) + end + + it 'denies a breadcrumb request for a container assessment no published listing serves' do + expect do + get :show, params: { course_id: container, id: unserved_assessment, crumb: true, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end +end + +# The lock must fire on the preview instance and nowhere else — the manager role it denies there is +# an ordinary one everywhere else. Deliberately outside the shared context: nothing about this needs +# the container to exist. +RSpec.describe Course::Assessment::AssessmentsController, 'outside the preview instance', type: :controller do + let(:instance) { Instance.default } + + with_tenant(:instance) do + let(:course) { create(:course) } + let!(:manager) { create(:course_manager, course: course) } + + before { controller_sign_in(controller, manager.user) } + + it 'leaves an ordinary course untouched' do + get :index, params: { course_id: course, format: :json } + expect(response).to have_http_status(:success) + end + end +end + +RSpec.describe Course::Assessment::Submission::SubmissionsController, type: :controller do + include_context 'marketplace preview sandbox' + + with_tenant(:preview_instance) do + before { controller_sign_in(controller, previewer) } + + # `attempt` routes here. It mints the submission every later action is scoped to by `creator_id`, + # so it is the one action that has to vet the assessment rather than ride on the submission. + it 'allows attempting the snapshot a published listing serves' do + get :create, params: { course_id: container, assessment_id: snapshot, format: :json } + expect(response).to have_http_status(:success) + end + + it 'refuses to mint a submission on a container assessment no published listing serves' do + expect do + get :create, params: { course_id: container, assessment_id: unserved_assessment, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + + # A manager's blanket `can :manage, Course::Assessment` satisfies `:publish_grades`, and no + # `cannot` revokes it — this action would otherwise publish grades for every previewer's + # submission on the snapshot. + it 'denies publishing grades for the whole assessment' do + expect do + patch :publish_all, params: { course_id: container, assessment_id: snapshot, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end +end + +RSpec.describe Course::Assessment::AssessmentsController, 'preview sandbox administrators', type: :controller do + include_context 'marketplace preview sandbox' + + let(:administrator) { ActsAsTenant.with_tenant(Instance.default) { create(:administrator) } } + + with_tenant(:preview_instance) do + before { controller_sign_in(controller, administrator) } + + # A system administrator curates the container from inside it, so the lock is per-viewer. Mirrors + # the exemption in Course::AssessmentMarketplaceAbilityComponent#define_permissions. + it 'is exempt from the lock' do + get :index, params: { course_id: container, format: :json } + expect(response).to have_http_status(:success) + end + end +end diff --git a/spec/controllers/course/assessment/marketplace/preview_submissions_controller_spec.rb b/spec/controllers/course/assessment/marketplace/preview_submissions_controller_spec.rb new file mode 100644 index 00000000000..8e279a31067 --- /dev/null +++ b/spec/controllers/course/assessment/marketplace/preview_submissions_controller_spec.rb @@ -0,0 +1,360 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::PreviewSubmissionsController, type: :controller do + let(:instance) { create(:instance) } + + with_tenant(:instance) do + let(:preview_course) { create(:course, preview: true) } + let(:assessment) { create(:assessment, :with_mcq_question, course: preview_course) } + let(:question) { assessment.questions.first.actable } + let(:previewer) { create(:user) } + let!(:previewer_course_user) { create(:course_manager, course: preview_course, user: previewer) } + + before { controller_sign_in(controller, previewer) } + + describe 'PATCH #update' do + subject do + patch :update, params: { course_id: preview_course, assessment_id: assessment, format: :json } + end + + context 'when the previewer has a submission for this assessment' do + let!(:submission) do + create(:submission, :attempting, assessment: assessment, course: preview_course, creator: previewer) + end + let!(:original_answer) do + answer = submission.reload.answers.first.specific + answer.options << question.options.first + answer.save! + answer + end + + it 'does not destroy the submission row' do + expect { subject }. + not_to(change { Course::Assessment::Submission.exists?(submission.id) }) + end + + it 'clears the current answer back to blank, destroying the old one outright' do + # `original_answer` is the MultipleResponse actable, not the base Answer — resolve back to + # the base Answer's own id (`acting_as`, aliased by the `acts_as` macro) BEFORE calling + # `subject`: the two records live in different tables with unrelated ids, and once the + # base Answer is destroyed, `original_answer.acting_as` can no longer look it up. + original_base_answer_id = original_answer.acting_as.id + + expect { subject }.not_to(change { submission.reload.answers.count }) + + submission.reload + expect(submission.current_answers.length).to eq(1) + + new_current_answer = submission.current_answers.first.specific + expect(new_current_answer.id).not_to eq(original_answer.id) + expect(new_current_answer.option_ids).to be_empty + + expect { Course::Assessment::Answer.find(original_base_answer_id) }. + to raise_error(ActiveRecord::RecordNotFound) + end + + it 'leaves an already-attempting submission in the attempting state' do + expect(submission.workflow_state).to eq('attempting') + subject + expect(submission.reload.workflow_state).to eq('attempting') + end + + it 'responds with no content' do + subject + expect(response).to have_http_status(:no_content) + end + end + + # Submission#reset_preview_answers loops over `current_answers`, one per question. A fixture + # with only one question cannot tell that loop apart from one that resets just the first answer. + context 'when the assessment has more than one question' do + let(:two_question_assessment) do + create(:assessment, :with_mcq_question, question_count: 2, course: preview_course) + end + let!(:submission) do + create(:submission, :attempting, assessment: two_question_assessment, course: preview_course, + creator: previewer) + end + + subject do + patch :update, params: { course_id: preview_course, assessment_id: two_question_assessment, + format: :json } + end + + it 'replaces every current answer, not just the first' do + old_answer_ids = submission.reload.current_answers.map(&:id) + expect(old_answer_ids.length).to eq(2) + + subject + + submission.reload + expect(submission.current_answers.length).to eq(2) + expect(Course::Assessment::Answer.where(id: old_answer_ids)).to be_empty + end + end + + # The user's real bug report: for a programming question, the old current answer's + # auto-grading record (stdout/stderr/exit code) and its per-test-case pass/fail rows must not + # survive a reset either — otherwise the "Past Answers" list and old test results still show + # up on the freshly-reset page. + context "when the previewer's current answer has programming test-case run results (auto-grading)" do + let(:programming_assessment) { create(:assessment, :with_programming_question, course: preview_course) } + let!(:submission) do + create(:submission, :attempting, assessment: programming_assessment, course: preview_course, + creator: previewer) + end + let(:programming_answer) { submission.reload.answers.first } + let!(:auto_grading) do + create(:course_assessment_answer_programming_auto_grading, answer: programming_answer) + end + let!(:test_result) do + create(:course_assessment_answer_programming_auto_grading_test_result, :failed, auto_grading: auto_grading) + end + + subject do + patch :update, params: { course_id: preview_course, assessment_id: programming_assessment, format: :json } + end + + it 'destroys the old answer, its auto-grading record, and its test-case results' do + programming_answer_id = programming_answer.id + auto_grading_id = auto_grading.id + test_result_id = test_result.id + + subject + + expect { Course::Assessment::Answer.find(programming_answer_id) }. + to raise_error(ActiveRecord::RecordNotFound) + expect { Course::Assessment::Answer::ProgrammingAutoGrading.find(auto_grading_id) }. + to raise_error(ActiveRecord::RecordNotFound) + expect { Course::Assessment::Answer::ProgrammingAutoGradingTestResult.find(test_result_id) }. + to raise_error(ActiveRecord::RecordNotFound) + end + end + + # A previewer holds `can :manage` on their own submission inside the sandbox, so they can + # finalise and then unsubmit — and unsubmit demotes the old answer to non-current instead of + # destroying it. Those leftover rows ARE the "Past Answers" trace the reset exists to erase, + # so they must go too, not just the current answer. + context 'when the previewer already has past (non-current) answers' do + let!(:submission) do + create(:submission, :attempting, assessment: assessment, course: preview_course, creator: previewer) + end + # Built by hand rather than with the `:attempting_with_past_answers` trait: that trait's + # `questions.attempt(submission)` REUSES an existing current answer instead of building a + # second one (QuestionsConcern#attempt:15), so it demotes the only answer there is and + # leaves the submission with a past answer and no current one. + let!(:past_answer) do + answer = assessment.questions.first.attempt(submission) + answer.current_answer = false + answer.save! + answer + end + + it 'destroys the past answers too, leaving one fresh blank current answer' do + past_answer_id = past_answer.id + expect(submission.reload.answers.count).to eq(2) + + expect { subject }.to change { submission.reload.answers.count }.from(2).to(1) + + expect(Course::Assessment::Answer.where(id: past_answer_id)).to be_empty + expect(submission.current_answers.length).to eq(1) + end + end + + context 'when the submission was already submitted (past attempting)' do + let!(:submission) do + create(:submission, :submitted, assessment: assessment, course: preview_course, creator: previewer) + end + + it 'forces the workflow_state back to attempting' do + expect(submission.workflow_state).to eq('submitted') + subject + expect(submission.reload.workflow_state).to eq('attempting') + end + + it 'clears submitted_at' do + expect(submission.submitted_at).not_to be_nil + subject + expect(submission.reload.submitted_at).to be_nil + end + end + + context 'when the submission was graded but not yet published' do + let!(:submission) do + create(:submission, :graded, assessment: assessment, course: preview_course, creator: previewer) + end + + it 'forces the workflow_state back to attempting and clears the draft grade' do + expect(submission.workflow_state).to eq('graded') + expect(submission.draft_points_awarded).not_to be_nil + + subject + submission.reload + + expect(submission.workflow_state).to eq('attempting') + expect(submission.draft_points_awarded).to be_nil + end + end + + context 'when the submission was already graded and published' do + let!(:submission) do + create(:submission, :published, assessment: assessment, course: preview_course, creator: previewer) + end + + it 'forces the workflow_state back to attempting' do + subject + expect(submission.reload.workflow_state).to eq('attempting') + end + + it 'clears all grading/publishing attributes' do + expect(submission.points_awarded).not_to be_nil + expect(submission.draft_points_awarded).not_to be_nil + expect(submission.awarded_at).not_to be_nil + expect(submission.awarder).not_to be_nil + expect(submission.publisher).not_to be_nil + expect(submission.published_at).not_to be_nil + + subject + submission.reload + + expect(submission.points_awarded).to be_nil + expect(submission.draft_points_awarded).to be_nil + expect(submission.awarded_at).to be_nil + expect(submission.awarder).to be_nil + expect(submission.submitted_at).to be_nil + expect(submission.publisher).to be_nil + expect(submission.published_at).to be_nil + end + end + + context 'when the current previewer has no submission for this assessment' do + it 'responds not found' do + subject + expect(response).to have_http_status(:not_found) + end + end + + # The submission is looked up through `@assessment.submissions`, never by an id from the + # client, so a submission the previewer owns for a DIFFERENT assessment in the same sandbox + # must not be reachable through this route either. + context 'when the previewer only has a submission for another assessment in the same course' do + let(:other_assessment) { create(:assessment, :with_mcq_question, course: preview_course) } + let!(:other_assessment_submission) do + create(:submission, :submitted, assessment: other_assessment, course: preview_course, creator: previewer) + end + + it 'responds not found' do + subject + expect(response).to have_http_status(:not_found) + end + + it "does not reset the other assessment's submission" do + subject + expect(other_assessment_submission.reload.workflow_state).to eq('submitted') + end + end + + # The container course is shared by every previewer, and every previewer is enrolled as + # `manager` — which under Course::Assessment::AssessmentAbility grants `:delete_submission` + # on ANY submission in the course, not just their own. This is the single riskiest scoping + # question in this feature, so it gets its own dedicated set of examples. + context "when another previewer's submission exists for the same assessment" do + let(:other_previewer) { create(:user) } + let!(:other_course_user) { create(:course_manager, course: preview_course, user: other_previewer) } + let!(:other_submission) do + create(:submission, :attempting, assessment: assessment, course: preview_course, creator: other_previewer) + end + + it "does not touch the other previewer's submission" do + other_workflow_state_before = other_submission.workflow_state + other_answer_count_before = other_submission.answers.count + + subject + + other_submission.reload + expect(other_submission.workflow_state).to eq(other_workflow_state_before) + expect(other_submission.answers.count).to eq(other_answer_count_before) + end + + it 'responds not found (the current previewer has nothing of their own to reset)' do + subject + expect(response).to have_http_status(:not_found) + end + end + + context 'when both the current previewer and another previewer have their own submissions' do + let(:other_previewer) { create(:user) } + let!(:other_course_user) { create(:course_manager, course: preview_course, user: other_previewer) } + let!(:own_submission) do + create(:submission, :submitted, assessment: assessment, course: preview_course, creator: previewer) + end + let!(:other_submission) do + create(:submission, :submitted, assessment: assessment, course: preview_course, creator: other_previewer) + end + + it "resets only the current previewer's own submission" do + subject + + expect(own_submission.reload.workflow_state).to eq('attempting') + expect(other_submission.reload.workflow_state).to eq('submitted') + end + end + + # `load_assessment` scopes the lookup to `current_course.assessments`, so an assessment id + # from another course cannot be driven through the preview course's route. + context 'when the assessment belongs to a different course' do + let(:other_course) { create(:course) } + let(:other_course_assessment) { create(:assessment, :with_mcq_question, course: other_course) } + + subject do + patch :update, params: { course_id: preview_course, assessment_id: other_course_assessment, + format: :json } + end + + it 'raises not found' do + expect { subject }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + # Enrolment is what admits someone to the sandbox: PreviewLaunchService enrols each previewer + # as a manager of the container course, and Course::Controller's + # `load_and_authorize_resource :course` denies anyone without a CourseUser there. + context 'when the user is not enrolled in the preview course' do + let(:outsider) { create(:user) } + + before { controller_sign_in(controller, outsider) } + + it 'denies access' do + expect { subject }.to raise_exception(CanCan::AccessDenied) + end + end + + context 'when the course is not a marketplace preview course' do + run_rescue + + let(:normal_course) { create(:course) } + let(:normal_assessment) { create(:assessment, :with_mcq_question, course: normal_course) } + let!(:normal_course_user) { create(:course_manager, course: normal_course, user: previewer) } + let!(:normal_submission) do + create(:submission, :attempting, assessment: normal_assessment, course: normal_course, creator: previewer) + end + + subject do + patch :update, params: { course_id: normal_course, assessment_id: normal_assessment, format: :json } + end + + it 'is forbidden' do + subject + expect(response).to have_http_status(:forbidden) + end + + it 'does not touch the submission' do + workflow_state_before = normal_submission.workflow_state + subject + expect(normal_submission.reload.workflow_state).to eq(workflow_state_before) + end + end + end + end +end diff --git a/spec/controllers/course/assessment/submission/submissions_controller_spec.rb b/spec/controllers/course/assessment/submission/submissions_controller_spec.rb index 5aa00a954e3..56cc1c05cd3 100644 --- a/spec/controllers/course/assessment/submission/submissions_controller_spec.rb +++ b/spec/controllers/course/assessment/submission/submissions_controller_spec.rb @@ -555,5 +555,47 @@ end end end + + context 'in the marketplace preview sandbox' do + # Own instance: at most one `preview: true` course may exist per instance. + let(:instance) { create(:instance) } + let(:preview_course) { create(:course, preview: true) } + let(:preview_assessment) { create(:assessment, :with_mcq_question, course: preview_course) } + let(:previewer) { create(:user) } + let!(:previewer_course_user) { create(:course_manager, course: preview_course, user: previewer) } + let(:own_submission) do + create(:submission, :attempting, assessment: preview_assessment, + course: preview_course, creator: previewer) + end + + before { controller_sign_in(controller, previewer) } + + describe '#edit' do + it 'allows the previewer to edit their OWN submission' do + get :edit, params: { course_id: preview_course, assessment_id: preview_assessment, + id: own_submission, format: :json } + expect(response).to be_successful + end + + it "forbids the previewer from reading ANOTHER previewer's submission" do + other_previewer = create(:course_manager, course: preview_course).user + other_submission = create(:submission, :attempting, assessment: preview_assessment, + course: preview_course, creator: other_previewer) + expect do + get :edit, params: { course_id: preview_course, assessment_id: preview_assessment, + id: other_submission, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end + + describe '#index' do + it 'forbids listing all submissions in the sandbox' do + own_submission + expect do + get :index, params: { course_id: preview_course, assessment_id: preview_assessment, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end + end end end diff --git a/spec/controllers/course/courses_controller_spec.rb b/spec/controllers/course/courses_controller_spec.rb index 38732d66a45..c6da19a7333 100644 --- a/spec/controllers/course/courses_controller_spec.rb +++ b/spec/controllers/course/courses_controller_spec.rb @@ -127,6 +127,85 @@ end end + describe '#sidebar' do + render_views + + let(:user) { create(:user) } + let(:course) { create(:course, published: true) } + let!(:course_user) { create(:course_student, course: course, user: user) } + + subject { get :sidebar, as: :json, params: { id: course } } + + before { controller_sign_in(controller, user) } + + context 'when the course is a normal course' do + it 'includes path on sidebar items' do + subject + sidebar_items = JSON.parse(response.body)['sidebar'] + expect(sidebar_items).to be_present + expect(sidebar_items).to all(have_key('path')) + end + end + + context 'when the course is a preview course' do + # Own instance: at most one `preview: true` course may exist per instance. + let(:instance) { create(:instance) } + let(:course) { create(:course, published: true, preview: true) } + + it 'omits path on sidebar items' do + subject + sidebar_items = JSON.parse(response.body)['sidebar'] + expect(sidebar_items).to be_present + expect(sidebar_items).not_to include(a_hash_including('path')) + end + + it 'reports the sandbox as restricted' do + subject + expect(JSON.parse(response.body)['isPreviewRestricted']).to eq(true) + end + end + + context 'when a system administrator views a preview course' do + # Own instance: at most one `preview: true` course may exist per instance. + let(:instance) { create(:instance) } + let(:user) { create(:administrator) } + let(:course) { create(:course, published: true, preview: true) } + let!(:course_user) { create(:course_manager, course: course, user: user) } + + it 'keeps sidebar items linked' do + subject + sidebar_items = JSON.parse(response.body)['sidebar'] + expect(sidebar_items).to be_present + expect(sidebar_items).to all(have_key('path')) + end + + it 'reports the sandbox as unrestricted' do + subject + expect(JSON.parse(response.body)['isPreviewRestricted']).to eq(false) + end + + it 'does not present them as a member of the sandbox' do + subject + expect(JSON.parse(response.body)).not_to have_key('courseUserRole') + end + end + + context 'when a system administrator views a normal course' do + let(:user) { create(:administrator) } + let!(:course_user) { create(:course_manager, course: course, user: user) } + + it 'presents them as a member' do + subject + expect(JSON.parse(response.body)['courseUserRole']).to eq('manager') + end + end + + it 'exposes isPreview matching the course' do + subject + expect(JSON.parse(response.body)['isPreview']).to eq(course.preview) + end + end + describe '#index' do context 'when there is no user logged in' do it 'allows unauthenticated access' do diff --git a/spec/controllers/course/gradebook_controller_spec.rb b/spec/controllers/course/gradebook_controller_spec.rb index eb8ddf2446c..f5ebc45f406 100644 --- a/spec/controllers/course/gradebook_controller_spec.rb +++ b/spec/controllers/course/gradebook_controller_spec.rb @@ -853,5 +853,46 @@ def weight_for(tab) expect(external_titles).to eq(%w[Alpha Zeta]) end end + + context 'in the marketplace preview sandbox' do + # Own instance: at most one `preview: true` course may exist per instance. + let(:instance) { create(:instance) } + let(:preview_course) { create(:course, preview: true) } + let(:previewer) { create(:course_manager, course: preview_course).user } + + before { controller_sign_in(controller, previewer) } + + it 'forbids reading the gradebook' do + expect do + get :index, params: { course_id: preview_course.id, format: :json } + end.to raise_error(CanCan::AccessDenied) + end + + it 'also forbids updating gradebook weights' do + # `authorize_read_gradebook!` is a `before_action` with no `only:` scoping + # (gradebook_controller.rb:5), so it gates #update_weights too, ahead of that + # action's own `authorize! :manage_gradebook_weights` check — read access is a + # prerequisite for write access on this controller. Task 1's `cannot + # :read_gradebook` therefore blocks this action as a side effect, which is + # consistent with (and reinforces) this plan's goal. + expect do + patch :update_weights, params: { course_id: preview_course.id, weights: [] }, format: :json + end.to raise_error(CanCan::AccessDenied) + end + end + + context 'when a system administrator visits the preview sandbox gradebook' do + # Own instance: at most one `preview: true` course may exist per instance. + let(:instance) { create(:instance) } + let(:preview_course) { create(:course, preview: true) } + let(:admin) { create(:administrator) } + + before { controller_sign_in(controller, admin) } + + it 'is not denied (admins are exempt from the preview restriction)' do + get :index, params: { course_id: preview_course.id }, format: :json + expect(response).to be_successful + end + end end end diff --git a/spec/controllers/course/users_controller_spec.rb b/spec/controllers/course/users_controller_spec.rb index 33acde7f24c..50ee2f53fb3 100644 --- a/spec/controllers/course/users_controller_spec.rb +++ b/spec/controllers/course/users_controller_spec.rb @@ -436,5 +436,42 @@ end end end + + context 'in the marketplace preview sandbox' do + # Own instance: at most one `preview: true` course may exist per instance. + let(:instance) { create(:instance) } + let(:preview_course) { create(:course, preview: true) } + let(:previewer_course_user) { create(:course_manager, course: preview_course) } + let(:previewer) { previewer_course_user.user } + + before { controller_sign_in(controller, previewer) } + + it 'forbids listing the users roster' do + expect do + get :index, params: { course_id: preview_course.id, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + + it "forbids reading another previewer's CourseUser record" do + other_course_user = create(:course_manager, course: preview_course) + expect do + get :show, params: { course_id: preview_course.id, id: other_course_user.id, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end + + context 'when a system administrator visits the preview sandbox users roster' do + # Own instance: at most one `preview: true` course may exist per instance. + let(:instance) { create(:instance) } + let(:preview_course) { create(:course, preview: true) } + let(:admin) { create(:administrator) } + + before { controller_sign_in(controller, admin) } + + it 'is not denied (admins are exempt from the preview restriction)' do + get :index, params: { course_id: preview_course.id, format: :json } + expect(response).to be_successful + end + end end end diff --git a/spec/models/course/assessment/marketplace/listing_spec.rb b/spec/models/course/assessment/marketplace/listing_spec.rb index 1c1adcd004f..5b70df6e52f 100644 --- a/spec/models/course/assessment/marketplace/listing_spec.rb +++ b/spec/models/course/assessment/marketplace/listing_spec.rb @@ -120,6 +120,44 @@ end end + # The preview sandbox lock's only per-assessment check (see + # spec/controllers/course/assessment/marketplace/preview_sandbox_lock_spec.rb), so each way of + # being "in the container but not served" is worth stating outright. + describe '.serving_assessment?' do + let(:listing) { create(:course_assessment_marketplace_listing, :versioned) } + let(:served) { listing.current_version.assessment } + + it 'is true for the assessment the current version points at' do + expect(described_class).to be_serving_assessment(served.id) + end + + it 'is false for an assessment no version points at' do + expect(described_class).not_to be_serving_assessment(listing.authoring_assessment.id) + end + + it 'is false for a superseded version' do + superseded = listing.current_version + newer = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: 1.hour.from_now, + published_by: listing.publisher) + listing.update!(current_version: newer) + + expect(described_class).not_to be_serving_assessment(superseded.assessment_id) + expect(described_class).to be_serving_assessment(newer.assessment_id) + end + + it 'is false once the listing is unlisted' do + listing.update!(published: false) + expect(described_class).not_to be_serving_assessment(served.id) + end + + it 'is false for a blank id, without querying for one' do + expect(described_class).not_to be_serving_assessment(nil) + end + end + describe 'maintenance predicates' do let(:listing) { create(:course_assessment_marketplace_listing, :versioned) } diff --git a/spec/models/course/assessment/submission_spec.rb b/spec/models/course/assessment/submission_spec.rb index 7da9fe68604..ae8b09f7081 100644 --- a/spec/models/course/assessment/submission_spec.rb +++ b/spec/models/course/assessment/submission_spec.rb @@ -435,6 +435,25 @@ def set_assessment_email_setting(course, category_id, setting, regular, phantom) expect { submission.publish! }.to change { ActionMailer::Base.deliveries.count }.by(1) end + context 'when the submission belongs to a marketplace preview rehearsal', type: :mailer do + # Own instance: at most one `preview: true` course may exist per instance. + let(:instance) { create(:instance) } + let(:course) { create(:course, preview: true) } + let(:course_student1) { create(:course_manager, course: course) } + + it 'does not send the graded email' do + expect { submission.publish! }.not_to(change { ActionMailer::Base.deliveries.count }) + end + + context 'when the course is not a preview sandbox' do + let(:course) { create(:course) } + + it 'still sends the graded email' do + expect { submission.publish! }.to change { ActionMailer::Base.deliveries.count }.by(1) + end + end + end + context 'when a user unsubscribes', type: :mailer do before do setting_email = course. diff --git a/spec/models/course/assessment_marketplace_ability_spec.rb b/spec/models/course/assessment_marketplace_ability_spec.rb index c1057398b30..17ba0849450 100644 --- a/spec/models/course/assessment_marketplace_ability_spec.rb +++ b/spec/models/course/assessment_marketplace_ability_spec.rb @@ -21,6 +21,7 @@ let(:user) { create(:administrator) } let(:course_user) { nil } it { is_expected.to be_able_to(:publish_to_marketplace, build(:assessment)) } + it { is_expected.to be_able_to(:access_marketplace, course) } end context 'when the user is a course manager' do @@ -154,6 +155,59 @@ it 'forbids deleting submissions in the sandbox' do expect(subject).not_to be_able_to(:delete_all_submissions, assessment) end + + it 'forbids the blanket delete_submission verb (would reach other previewers\' copies)' do + own_submission = create(:submission, :attempting, assessment: assessment, + course: course, creator: user) + expect(subject).not_to be_able_to(:delete_submission, own_submission) + end + + it 'permits the narrowly-scoped self-reset of their OWN submission' do + own_submission = create(:submission, :attempting, assessment: assessment, + course: course, creator: user) + expect(subject).to be_able_to(:reset_own_preview_submission, own_submission) + end + + it "forbids self-reset of ANOTHER previewer's submission" do + other_previewer = create(:course_manager, course: course).user + other_submission = create(:submission, :attempting, assessment: assessment, + course: course, creator: other_previewer) + expect(subject).not_to be_able_to(:reset_own_preview_submission, other_submission) + end + + it 'permits reading/updating their OWN submission via the broad grant' do + own_submission = create(:submission, :attempting, assessment: assessment, + course: course, creator: user) + expect(subject).to be_able_to(:read, own_submission) + expect(subject).to be_able_to(:update, own_submission) + end + + it "forbids reading/updating ANOTHER previewer's submission" do + other_previewer = create(:course_manager, course: course).user + other_submission = create(:submission, :attempting, assessment: assessment, + course: course, creator: other_previewer) + expect(subject).not_to be_able_to(:read, other_submission) + expect(subject).not_to be_able_to(:update, other_submission) + end + + it 'forbids listing all submissions for an assessment in the sandbox' do + expect(subject).not_to be_able_to(:view_all_submissions, assessment) + end + + it 'forbids reading the gradebook' do + expect(subject).not_to be_able_to(:read_gradebook, course) + end + + it 'forbids the show_users/manage_users roster views' do + expect(subject).not_to be_able_to(:show_users, course) + expect(subject).not_to be_able_to(:manage_users, course) + end + + it "forbids reading anyone's CourseUser record, including their own" do + expect(subject).not_to be_able_to(:show, course_user) + other_course_user = create(:course_manager, course: course) + expect(subject).not_to be_able_to(:show, other_course_user) + end end context 'and the user is a system administrator' do @@ -164,6 +218,13 @@ expect(subject).to be_able_to(:update, assessment) expect(subject).to be_able_to(:destroy, assessment) end + + it 'retains gradebook, users, and cross-submission access' do + expect(subject).to be_able_to(:read_gradebook, course) + other_submission = create(:submission, :attempting, assessment: assessment, course: course, + creator: create(:course_manager, course: course).user) + expect(subject).to be_able_to(:read, other_submission) + end end end @@ -177,6 +238,18 @@ expect(subject).to be_able_to(:update, assessment) expect(subject).to be_able_to(:destroy, assessment) end + + it 'never grants the preview-only self-reset verb outside a preview course' do + submission = create(:submission, :attempting, assessment: assessment, course: course, creator: user) + expect(subject).not_to be_able_to(:reset_own_preview_submission, submission) + end + + it 'retains gradebook, users, and cross-submission access (the restriction is preview-scoped)' do + expect(subject).to be_able_to(:read_gradebook, course) + other_submission = create(:submission, :attempting, assessment: assessment, course: course, + creator: create(:course_manager, course: course).user) + expect(subject).to be_able_to(:read, other_submission) + end end end end diff --git a/spec/models/course/discussion/topic_spec.rb b/spec/models/course/discussion/topic_spec.rb index ec1003272a2..15467c4038d 100644 --- a/spec/models/course/discussion/topic_spec.rb +++ b/spec/models/course/discussion/topic_spec.rb @@ -53,6 +53,41 @@ end end + describe '#mark_as_pending' do + let(:course) { create(:course) } + # The actable is built with the same course on purpose: `:course_discussion_topic`'s default + # actable is a forum topic that brings its OWN course, and Course::Forum::Topic#set_course then + # overwrites `course_id` with the forum's — so a bare `course:` here is silently discarded and + # the topic ends up in a different, non-preview course. + let(:discussion_topic) do + create(:course_discussion_topic, course: course, actable: build(:forum_topic, course: course)) + end + + it 'marks the topic as pending staff reply' do + expect(discussion_topic.mark_as_pending).to be_truthy + expect(discussion_topic.reload.pending_staff_reply).to eq(true) + end + + context 'when the topic is in the marketplace preview sandbox' do + # Own instance: at most one `preview: true` course may exist per instance. + let(:instance) { create(:instance) } + let(:course) { create(:course, preview: true) } + + it 'leaves the topic unmarked' do + discussion_topic.mark_as_pending + + expect(discussion_topic.reload.pending_staff_reply).to eq(false) + end + + # The writers treat a falsey return as a failure worth rolling the whole post creation back + # (see Course::Discussion::PostsConcern#update_topic_pending_status), so suppression must + # report success. + it 'still reports success' do + expect(discussion_topic.mark_as_pending).to be_truthy + end + end + end + describe '#ensure_subscribed_by' do context 'when the user has subscribed to a topic' do let!(:discussion_topic_subscription) do diff --git a/spec/services/course/assessment/marketplace/preview_launch_service_spec.rb b/spec/services/course/assessment/marketplace/preview_launch_service_spec.rb new file mode 100644 index 00000000000..2f53eaa662a --- /dev/null +++ b/spec/services/course/assessment/marketplace/preview_launch_service_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::PreviewLaunchService, type: :service do + # The listing's own course, and the cross-tenant preview instance/container course, both live + # under the default tenant for this spec — same shape as PreviewContainerService's spec. + let!(:default_instance) { Instance.default } + + with_tenant(:default_instance) do + let(:source_course) { create(:course) } + let(:source_assessment) { create(:assessment, :with_mcq_question, course: source_course) } + # Published through the real service rather than the `:versioned` factory trait: that trait parks + # its stand-in snapshot in the origin's own course, while everything asserted here is about a + # snapshot that lives in — and is attempted from — the container. + let(:listing) do + Course::Assessment::Marketplace::PublishService.publish(source_assessment, source_course.creator) + end + let(:user) { create(:user) } + let(:preview_instance) { Course::Assessment::Marketplace::PreviewContainerService.preview_instance } + let(:course) { Course::Assessment::Marketplace::PreviewContainerService.container_course } + let(:snapshot) { listing.current_version.assessment } + + describe '.launch' do + # The point of the converged design: publishing already placed the snapshot in the container, so + # launching a preview copies nothing. Duplicating per preview is exactly what would let a + # preview drift from the copy an adopter's duplicate produces. + it 'duplicates nothing — it attempts the snapshot that publishing placed in the container' do + listing # publish before measuring, so the snapshot itself is not counted as the delta + + expect { described_class.launch(listing, user) }. + not_to(change { course.assessments.count }) + end + + it 'returns the absolute attempt URL for the snapshot on the preview host' do + url = described_class.launch(listing, user) + + expect(url).to start_with('https://preview.') + expect(url).to include("/courses/#{course.id}/") + expect(url).to include("/assessments/#{snapshot.id}/") + expect(url).to end_with('/attempt') + end + + it 'attempts the container snapshot, never the authoring copy' do + url = described_class.launch(listing, user) + + expect(snapshot.course).to eq(course) + expect(snapshot.id).not_to eq(listing.authoring_assessment_id) + expect(url).not_to include("/assessments/#{listing.authoring_assessment_id}/") + end + + it 'enrols the previewer as a manager on first launch' do + # Force the listing (and its own source course) into existence before the assertion block: + # creating a course also creates an `owner` CourseUser for its creator, which would otherwise + # inflate this delta since CourseUser.count is global, not scoped to the container course. + listing + user + + expect { described_class.launch(listing, user) }. + to change { CourseUser.count }.by(1) + + course_user = course.course_users.find_by(user: user) + expect(course_user).to be_manager + expect(ActsAsTenant.with_tenant(preview_instance) { InstanceUser.exists?(user: user) }).to be true + end + + it 'does not re-enrol the previewer on re-launch' do + described_class.launch(listing, user) + + expect { described_class.launch(listing, user) }.not_to(change { CourseUser.count }) + end + + it 'returns the same attempt URL on re-launch' do + first = described_class.launch(listing, user) + + expect(described_class.launch(listing, user)).to eq(first) + end + end + end +end