diff --git a/app/controllers/system/admin/marketplace_allowlist_rules_controller.rb b/app/controllers/system/admin/marketplace_allowlist_rules_controller.rb new file mode 100644 index 00000000000..59d4617fdb3 --- /dev/null +++ b/app/controllers/system/admin/marketplace_allowlist_rules_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +class System::Admin::MarketplaceAllowlistRulesController < System::Admin::Controller + load_and_authorize_resource :allowlist_rule, + class: 'Course::Assessment::Marketplace::AllowlistRule', + parent: false + + def index + # "Everyone" is a page-level mode, not a table row: expose its presence as `@everyone_rule` + # and show only the scoped rules in the table. + @everyone_rule = @allowlist_rules.rule_type_everyone.first + @allowlist_rules = @allowlist_rules.where.not(rule_type: :everyone).includes(:user, :instance) + end + + def create + if @allowlist_rule.save + # `render partial:` (not `render 'rule'`) — the view is the `_rule` partial. Mirrors + # System::Admin::AnnouncementsController#create (`render partial: '.../announcement_data'`). + render partial: 'rule', locals: { rule: @allowlist_rule }, status: :ok + else + render json: { errors: @allowlist_rule.errors.full_messages.to_sentence }, status: :bad_request + end + end + + def destroy + if @allowlist_rule.destroy + head :ok + else + render json: { errors: @allowlist_rule.errors.full_messages.to_sentence }, status: :bad_request + end + end + + private + + def allowlist_rule_params + params.require(:allowlist_rule).permit(:rule_type, :user_id, :instance_id, :email_domain) + end +end diff --git a/app/models/components/course/assessment_marketplace_ability_component.rb b/app/models/components/course/assessment_marketplace_ability_component.rb index e7a7972085c..9f96bdbf251 100644 --- a/app/models/components/course/assessment_marketplace_ability_component.rb +++ b/app/models/components/course/assessment_marketplace_ability_component.rb @@ -4,12 +4,26 @@ module Course::AssessmentMarketplaceAbilityComponent def define_permissions allow_admins_publish_to_marketplace if user&.administrator? - allow_managers_access_marketplace if course_user&.manager_or_owner? + if course_user&.manager_or_owner? + if marketplace_visible_to_user? + allow_managers_access_marketplace + else + # `Course::CourseAbilityComponent` grants managers/owners a blanket `can :manage, Course`, + # which (CanCan's `:manage` matches any action) would otherwise satisfy + # `:access_marketplace` regardless of the allow-list. This component runs after that one + # in the `define_permissions` super chain, so a `cannot` here takes precedence. + cannot :access_marketplace, Course, id: course.id + end + end super end private + def marketplace_visible_to_user? + user&.administrator? || Course::Assessment::Marketplace::AllowlistRule.grants_access?(user) + end + def allow_admins_publish_to_marketplace can :publish_to_marketplace, Course::Assessment end diff --git a/app/models/course/assessment/marketplace/allowlist_rule.rb b/app/models/course/assessment/marketplace/allowlist_rule.rb new file mode 100644 index 00000000000..0d0f60abc0d --- /dev/null +++ b/app/models/course/assessment/marketplace/allowlist_rule.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::AllowlistRule < ApplicationRecord + enum :rule_type, { user: 0, instance: 1, email_domain: 2, everyone: 3 }, prefix: true + + belongs_to :user, class_name: 'User', inverse_of: false, optional: true + belongs_to :instance, inverse_of: false, optional: true + + validates :user, presence: true, if: :rule_type_user? + validates :instance, presence: true, if: :rule_type_instance? + validates :email_domain, presence: true, if: :rule_type_email_domain? + # "Everyone" is the widest rule; only one may exist. Paired with a partial unique index. + validates :rule_type, uniqueness: true, if: :rule_type_everyone? + + # Whether the marketplace is visible to +user+ per the allow-list. The rules table itself is + # global (not tenant-scoped), but `user.instance_users` IS tenant-scoped (acts_as_tenant), so + # in a request an `instance` rule matches only while browsing the allow-listed instance — + # it grants that instance's users access *there*, not membership-based access everywhere. + # An `everyone` rule grants every authenticated user (the `nil` guard still excludes anonymous). + # @param [User] user + # @return [Boolean] + def self.grants_access?(user) + return false unless user + + rule_type_everyone.exists? || + rule_type_user.where(user_id: user.id).exists? || + rule_type_instance.where(instance_id: user.instance_users.select(:instance_id)).exists? || + email_domain_matches?(user) + end + + # @param [User] user + # @return [Boolean] + def self.email_domain_matches?(user) + domains = user.emails.pluck(:email).filter_map { |e| e.split('@').last&.downcase }.uniq + return false if domains.empty? + + rule_type_email_domain.where('LOWER(email_domain) IN (?)', domains).exists? + end +end diff --git a/app/views/system/admin/marketplace_allowlist_rules/_rule.json.jbuilder b/app/views/system/admin/marketplace_allowlist_rules/_rule.json.jbuilder new file mode 100644 index 00000000000..b2d3d5ee1a5 --- /dev/null +++ b/app/views/system/admin/marketplace_allowlist_rules/_rule.json.jbuilder @@ -0,0 +1,8 @@ +# frozen_string_literal: true +json.id rule.id +json.ruleType rule.rule_type +json.userId rule.user_id +json.userName rule.user&.name +json.instanceId rule.instance_id +json.instanceName rule.instance&.name +json.emailDomain rule.email_domain diff --git a/app/views/system/admin/marketplace_allowlist_rules/index.json.jbuilder b/app/views/system/admin/marketplace_allowlist_rules/index.json.jbuilder new file mode 100644 index 00000000000..2b07f8a2ad8 --- /dev/null +++ b/app/views/system/admin/marketplace_allowlist_rules/index.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true +json.rules @allowlist_rules do |rule| + json.partial! 'rule', rule: rule +end +json.everyoneRuleId @everyone_rule&.id diff --git a/client/app/api/system/Admin.ts b/client/app/api/system/Admin.ts index 40eac58adc4..fba95ab3c9f 100644 --- a/client/app/api/system/Admin.ts +++ b/client/app/api/system/Admin.ts @@ -5,6 +5,10 @@ import { } from 'types/course/announcements'; import { CourseListData } from 'types/system/courses'; import { InstanceListData, InstancePermissions } from 'types/system/instances'; +import { + AllowlistRuleData, + AllowlistRuleFormData, +} from 'types/system/marketplaceAllowlist'; import { AdminStats, UserListData } from 'types/users'; import BaseSystemAPI from '../Base'; @@ -173,4 +177,54 @@ export default class AdminAPI extends BaseSystemAPI { getDeploymentInfo(): Promise> { return this.client.get(`${AdminAPI.#urlPrefix}/deployment_info`); } + + /** + * Fetches the marketplace allow-list rules. + */ + indexMarketplaceAllowlistRules(): Promise< + AxiosResponse<{ rules: AllowlistRuleData[]; everyoneRuleId: number | null }> + > { + return this.client.get( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules`, + ); + } + + /** + * Creates a marketplace allow-list rule. + */ + createMarketplaceAllowlistRule( + params: AllowlistRuleFormData, + ): Promise> { + return this.client.post( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules`, + { + allowlist_rule: { + rule_type: params.ruleType, + user_id: params.userId, + instance_id: params.instanceId, + email_domain: params.emailDomain, + }, + }, + ); + } + + /** + * Opens the marketplace to everyone by creating the single `everyone` allow-list rule. + * Returns the created rule; only its `id` is consumed (to later restrict). + */ + openMarketplaceToEveryone(): Promise> { + return this.client.post( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules`, + { allowlist_rule: { rule_type: 'everyone' } }, + ); + } + + /** + * Deletes a marketplace allow-list rule. + */ + deleteMarketplaceAllowlistRule(id: number): Promise { + return this.client.delete( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules/${id}`, + ); + } } diff --git a/client/app/bundles/system/admin/admin/AdminNavigator.tsx b/client/app/bundles/system/admin/admin/AdminNavigator.tsx index c445a5b9d15..ddb5875f3c7 100644 --- a/client/app/bundles/system/admin/admin/AdminNavigator.tsx +++ b/client/app/bundles/system/admin/admin/AdminNavigator.tsx @@ -5,6 +5,7 @@ import { Category, Chat, Group, + Storefront, } from '@mui/icons-material'; import useTranslation from 'lib/hooks/useTranslation'; @@ -32,6 +33,10 @@ const translations = defineMessages({ id: 'system.admin.admin.AdminNavigator.getHelp', defaultMessage: 'Get Help', }, + marketplace: { + id: 'system.admin.admin.AdminNavigator.marketplace', + defaultMessage: 'Marketplace Access', + }, systemAdminPanel: { id: 'system.admin.admin.AdminNavigator.systemAdminPanel', defaultMessage: 'System Admin Panel', @@ -64,6 +69,11 @@ const AdminNavigator = (): JSX.Element => { title: t(translations.courses), path: '/admin/courses', }, + { + icon: , + title: t(translations.marketplace), + path: '/admin/marketplace_allowlist_rules', + }, { icon: , title: t(translations.getHelp), diff --git a/client/app/bundles/system/admin/admin/components/MarketplaceAllowlistModeBanner.tsx b/client/app/bundles/system/admin/admin/components/MarketplaceAllowlistModeBanner.tsx new file mode 100644 index 00000000000..067b135ce8c --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/MarketplaceAllowlistModeBanner.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert, Button } from '@mui/material'; + +import Prompt from 'lib/components/core/dialogs/Prompt'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface Props { + openToEveryone: boolean; + onOpenToEveryone: () => Promise; + onRestrict: () => Promise; +} + +const translations = defineMessages({ + scopedTitle: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.scopedTitle', + defaultMessage: 'Access is limited to the rules below.', + }, + everyoneTitle: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.everyoneTitle', + defaultMessage: + 'The marketplace is open to all course managers. The rules below are preserved but inactive.', + }, + openButton: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.openButton', + defaultMessage: 'Open to everyone', + }, + restrictButton: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.restrictButton', + defaultMessage: 'Restrict to scoped rules', + }, + openConfirmTitle: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmTitle', + defaultMessage: 'Open marketplace to everyone?', + }, + openConfirmBody: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmBody', + defaultMessage: + 'This makes the marketplace visible to all course managers and owners in every instance. You can restrict it again at any time; your scoped rules are kept.', + }, + restrictConfirmTitle: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmTitle', + defaultMessage: 'Restrict to scoped rules?', + }, + restrictConfirmBody: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmBody', + defaultMessage: + 'The marketplace will again be limited to the rules below. Managers not covered by a rule will lose access.', + }, + confirmOpen: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.confirmOpen', + defaultMessage: 'Open to everyone', + }, + confirmRestrict: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.confirmRestrict', + defaultMessage: 'Restrict', + }, +}); + +const MarketplaceAllowlistModeBanner = ({ + openToEveryone, + onOpenToEveryone, + onRestrict, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const handleConfirm = async (): Promise => { + setSubmitting(true); + try { + await (openToEveryone ? onRestrict() : onOpenToEveryone()); + setIsConfirmOpen(false); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + setIsConfirmOpen(true)} + size="small" + > + {openToEveryone + ? t(translations.restrictButton) + : t(translations.openButton)} + + } + className="mb-4" + severity={openToEveryone ? 'success' : 'info'} + > + {openToEveryone + ? t(translations.everyoneTitle) + : t(translations.scopedTitle)} + + + setIsConfirmOpen(false)} + open={isConfirmOpen} + primaryColor={openToEveryone ? 'error' : 'primary'} + primaryDisabled={submitting} + primaryLabel={ + openToEveryone + ? t(translations.confirmRestrict) + : t(translations.confirmOpen) + } + title={ + openToEveryone + ? t(translations.restrictConfirmTitle) + : t(translations.openConfirmTitle) + } + > + {openToEveryone + ? t(translations.restrictConfirmBody) + : t(translations.openConfirmBody)} + + + ); +}; + +export default MarketplaceAllowlistModeBanner; diff --git a/client/app/bundles/system/admin/admin/components/forms/MarketplaceAllowlistRuleForm.tsx b/client/app/bundles/system/admin/admin/components/forms/MarketplaceAllowlistRuleForm.tsx new file mode 100644 index 00000000000..e19d9ec28b2 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/forms/MarketplaceAllowlistRuleForm.tsx @@ -0,0 +1,140 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { MenuItem, TextField } from '@mui/material'; +import { + AllowlistRuleFormData, + AllowlistRuleType, +} from 'types/system/marketplaceAllowlist'; + +import Prompt from 'lib/components/core/dialogs/Prompt'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface Props { + open: boolean; + onClose: () => void; + onSubmit: (data: AllowlistRuleFormData) => Promise; +} + +const translations = defineMessages({ + title: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.title', + defaultMessage: 'Add marketplace access rule', + }, + ruleType: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.ruleType', + defaultMessage: 'Rule type', + }, + typeUser: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.typeUser', + defaultMessage: 'Specific user', + }, + typeInstance: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.typeInstance', + defaultMessage: 'All users in an instance', + }, + typeEmailDomain: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.typeEmailDomain', + defaultMessage: 'All users with an email domain', + }, + userId: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.userId', + defaultMessage: 'User ID', + }, + instanceId: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.instanceId', + defaultMessage: 'Instance ID', + }, + emailDomain: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.emailDomain', + defaultMessage: 'Email domain (e.g. schools.gov.sg)', + }, + add: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.add', + defaultMessage: 'Add', + }, +}); + +const MarketplaceAllowlistRuleForm = ({ + open, + onClose, + onSubmit, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [ruleType, setRuleType] = useState('email_domain'); + const [value, setValue] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const reset = (): void => { + setRuleType('email_domain'); + setValue(''); + }; + + const handleClose = (): void => { + reset(); + onClose(); + }; + + const buildData = (): AllowlistRuleFormData => { + switch (ruleType) { + case 'user': + return { ruleType, userId: parseInt(value, 10) }; + case 'instance': + return { ruleType, instanceId: parseInt(value, 10) }; + default: + return { ruleType, emailDomain: value.trim() }; + } + }; + + const submit = async (): Promise => { + setSubmitting(true); + await onSubmit(buildData()).finally(() => setSubmitting(false)); + reset(); + }; + + const valueLabel = { + user: t(translations.userId), + instance: t(translations.instanceId), + email_domain: t(translations.emailDomain), + }[ruleType]; + + return ( + +
+ { + setRuleType(e.target.value as AllowlistRuleType); + setValue(''); + }} + select + value={ruleType} + > + {t(translations.typeUser)} + {t(translations.typeInstance)} + + {t(translations.typeEmailDomain)} + + + + setValue(e.target.value)} + type={ruleType === 'email_domain' ? 'text' : 'number'} + value={value} + /> +
+
+ ); +}; + +export default MarketplaceAllowlistRuleForm; diff --git a/client/app/bundles/system/admin/admin/components/tables/MarketplaceAllowlistTable.tsx b/client/app/bundles/system/admin/admin/components/tables/MarketplaceAllowlistTable.tsx new file mode 100644 index 00000000000..6f34d899703 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/tables/MarketplaceAllowlistTable.tsx @@ -0,0 +1,110 @@ +import { defineMessages } from 'react-intl'; +import { AllowlistRuleData } from 'types/system/marketplaceAllowlist'; + +import DeleteButton from 'lib/components/core/buttons/DeleteButton'; +import Table, { ColumnTemplate } from 'lib/components/table'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface Props { + rules: AllowlistRuleData[]; + onDelete: (id: number) => Promise; + disabled?: boolean; +} + +const translations = defineMessages({ + colType: { + id: 'system.admin.admin.MarketplaceAllowlistTable.colType', + defaultMessage: 'Type', + }, + colTarget: { + id: 'system.admin.admin.MarketplaceAllowlistTable.colTarget', + defaultMessage: 'Grants access to', + }, + colActions: { + id: 'system.admin.admin.MarketplaceAllowlistTable.colActions', + defaultMessage: 'Actions', + }, + typeUser: { + id: 'system.admin.admin.MarketplaceAllowlistTable.typeUser', + defaultMessage: 'User', + }, + typeInstance: { + id: 'system.admin.admin.MarketplaceAllowlistTable.typeInstance', + defaultMessage: 'Instance', + }, + typeEmailDomain: { + id: 'system.admin.admin.MarketplaceAllowlistTable.typeEmailDomain', + defaultMessage: 'Email domain', + }, + deleteConfirm: { + id: 'system.admin.admin.MarketplaceAllowlistTable.deleteConfirm', + defaultMessage: 'Remove this marketplace access rule?', + }, + empty: { + id: 'system.admin.admin.MarketplaceAllowlistTable.empty', + defaultMessage: + 'No access rules yet. The marketplace is hidden from everyone except system administrators.', + }, +}); + +const MarketplaceAllowlistTable = ({ + rules, + onDelete, + disabled = false, +}: Props): JSX.Element => { + const { t } = useTranslation(); + + const typeLabels: Record = { + user: t(translations.typeUser), + instance: t(translations.typeInstance), + email_domain: t(translations.typeEmailDomain), + }; + + const targetOf = (rule: AllowlistRuleData): string => { + switch (rule.ruleType) { + case 'user': + return rule.userName ?? `#${rule.userId}`; + case 'instance': + return rule.instanceName ?? `#${rule.instanceId}`; + default: + return rule.emailDomain ?? ''; + } + }; + + const columns: ColumnTemplate[] = [ + { + of: 'ruleType', + title: t(translations.colType), + cell: (rule) => typeLabels[rule.ruleType], + }, + { + id: 'target', + title: t(translations.colTarget), + cell: (rule) => targetOf(rule), + }, + { + id: 'actions', + title: t(translations.colActions), + cell: (rule) => ( + => onDelete(rule.id)} + /> + ), + }, + ]; + + return ( +
+ rule.id.toString()} + renderEmpty={t(translations.empty)} + /> + + ); +}; + +export default MarketplaceAllowlistTable; diff --git a/client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx b/client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx new file mode 100644 index 00000000000..b169557dabe --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx @@ -0,0 +1,164 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import { + AllowlistRuleData, + AllowlistRuleFormData, +} from 'types/system/marketplaceAllowlist'; + +import SystemAPI from 'api/system'; +import AddButton from 'lib/components/core/buttons/AddButton'; +import Page from 'lib/components/core/layouts/Page'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import toast from 'lib/hooks/toast'; + +import MarketplaceAllowlistRuleForm from '../components/forms/MarketplaceAllowlistRuleForm'; +import MarketplaceAllowlistModeBanner from '../components/MarketplaceAllowlistModeBanner'; +import MarketplaceAllowlistTable from '../components/tables/MarketplaceAllowlistTable'; + +type Props = WrappedComponentProps; + +const translations = defineMessages({ + header: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.header', + defaultMessage: 'Marketplace Access', + }, + addRule: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.addRule', + defaultMessage: 'Add rule', + }, + fetchFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.fetchFailure', + defaultMessage: 'Failed to load marketplace access rules.', + }, + createSuccess: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.createSuccess', + defaultMessage: 'Access rule added.', + }, + createFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.createFailure', + defaultMessage: 'Failed to add access rule.', + }, + deleteSuccess: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.deleteSuccess', + defaultMessage: 'Access rule removed.', + }, + deleteFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.deleteFailure', + defaultMessage: 'Failed to remove access rule.', + }, + openSuccess: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.openSuccess', + defaultMessage: 'Marketplace opened to all course managers.', + }, + openFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.openFailure', + defaultMessage: 'Failed to open the marketplace to everyone.', + }, + restrictSuccess: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.restrictSuccess', + defaultMessage: 'Marketplace restricted to the scoped rules.', + }, + restrictFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.restrictFailure', + defaultMessage: 'Failed to restrict the marketplace.', + }, +}); + +const MarketplaceAllowlistIndex: FC = ({ intl }) => { + const [isLoading, setIsLoading] = useState(true); + const [isFormOpen, setIsFormOpen] = useState(false); + const [rules, setRules] = useState([]); + const [everyoneRuleId, setEveryoneRuleId] = useState(null); + + useEffect(() => { + SystemAPI.admin + .indexMarketplaceAllowlistRules() + .then((response) => { + setRules(response.data.rules); + setEveryoneRuleId(response.data.everyoneRuleId ?? null); + }) + .catch(() => toast.error(intl.formatMessage(translations.fetchFailure))) + .finally(() => setIsLoading(false)); + }, []); + + const openToEveryone = everyoneRuleId !== null; + + const handleCreate = async (data: AllowlistRuleFormData): Promise => { + try { + const response = + await SystemAPI.admin.createMarketplaceAllowlistRule(data); + setRules((current) => [...current, response.data]); + toast.success(intl.formatMessage(translations.createSuccess)); + setIsFormOpen(false); + } catch { + toast.error(intl.formatMessage(translations.createFailure)); + } + }; + + const handleDelete = async (id: number): Promise => { + try { + await SystemAPI.admin.deleteMarketplaceAllowlistRule(id); + setRules((current) => current.filter((rule) => rule.id !== id)); + toast.success(intl.formatMessage(translations.deleteSuccess)); + } catch { + toast.error(intl.formatMessage(translations.deleteFailure)); + } + }; + + const handleOpenToEveryone = async (): Promise => { + try { + const response = await SystemAPI.admin.openMarketplaceToEveryone(); + setEveryoneRuleId(response.data.id); + toast.success(intl.formatMessage(translations.openSuccess)); + } catch { + toast.error(intl.formatMessage(translations.openFailure)); + } + }; + + const handleRestrict = async (): Promise => { + if (everyoneRuleId === null) return; + try { + await SystemAPI.admin.deleteMarketplaceAllowlistRule(everyoneRuleId); + setEveryoneRuleId(null); + toast.success(intl.formatMessage(translations.restrictSuccess)); + } catch { + toast.error(intl.formatMessage(translations.restrictFailure)); + } + }; + + if (isLoading) return ; + + return ( + + setIsFormOpen(true)} + > + {intl.formatMessage(translations.addRule)} + + + + + + + setIsFormOpen(false)} + onSubmit={handleCreate} + open={isFormOpen} + /> + + ); +}; + +export default injectIntl(MarketplaceAllowlistIndex); diff --git a/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx new file mode 100644 index 00000000000..8a9d262272d --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx @@ -0,0 +1,146 @@ +import userEvent from '@testing-library/user-event'; +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor, within } from 'test-utils'; + +import SystemAPI from 'api/system'; + +import MarketplaceAllowlistIndex from '../MarketplaceAllowlistIndex'; + +const mock = createMockAdapter(SystemAPI.admin.client); +beforeEach(() => mock.reset()); + +const INDEX_URL = '/admin/marketplace_allowlist_rules'; +const EMAIL_DOMAIN = 'schools.gov.sg'; +const RULES = [ + { + id: 1, + ruleType: 'email_domain', + userId: null, + userName: null, + instanceId: null, + instanceName: null, + emailDomain: EMAIL_DOMAIN, + }, +]; + +it('renders the allow-list rules from the API', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + const page = render(, { at: [INDEX_URL] }); + + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); +}); + +it('creates an email-domain rule from the add dialog', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onPost(INDEX_URL).reply(200, { + id: 2, + ruleType: 'email_domain', + userId: null, + userName: null, + instanceId: null, + instanceName: null, + emailDomain: 'nus.edu.sg', + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(mock.history.get).toHaveLength(1)); + + fireEvent.click(page.getByText('Add rule')); + // Rule type defaults to Email domain; fill the value field. (Search fields need userEvent — + // see client/CLAUDE-testing.md; a plain TextField accepts userEvent.type too.) + await userEvent.type( + page.getByLabelText('Email domain (e.g. schools.gov.sg)'), + 'nus.edu.sg', + ); + fireEvent.click(page.getByRole('button', { name: 'Add' })); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ + allowlist_rule: { rule_type: 'email_domain', email_domain: 'nus.edu.sg' }, + }); + await waitFor(() => expect(page.getByText('nus.edu.sg')).toBeVisible()); +}); + +it('deletes a rule after confirmation', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + mock.onDelete(`${INDEX_URL}/1`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + + fireEvent.click(page.getByTestId('DeleteIconButton')); + fireEvent.click(page.getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + await waitFor(() => + expect(page.queryByText(EMAIL_DOMAIN)).not.toBeInTheDocument(), + ); +}); + +it('opens the marketplace to everyone from the banner', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: null }); + mock.onPost(INDEX_URL).reply(200, { id: 99 }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + + // Scoped state: banner offers "Open to everyone". + fireEvent.click(page.getByRole('button', { name: 'Open to everyone' })); + + // Confirm inside the dialog (its primary button shares the label, so scope to the dialog). + const dialog = page.getByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: 'Open to everyone' }), + ); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ + allowlist_rule: { rule_type: 'everyone' }, + }); + await waitFor(() => + expect( + page.getByText( + 'The marketplace is open to all course managers. The rules below are preserved but inactive.', + ), + ).toBeVisible(), + ); +}); + +it('restricts the marketplace to scoped rules from the banner', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + mock.onDelete(`${INDEX_URL}/42`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => + expect( + page.getByText( + 'The marketplace is open to all course managers. The rules below are preserved but inactive.', + ), + ).toBeVisible(), + ); + + fireEvent.click( + page.getByRole('button', { name: 'Restrict to scoped rules' }), + ); + const dialog = page.getByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Restrict' })); + + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + expect(mock.history.delete[0].url).toBe(`${INDEX_URL}/42`); + await waitFor(() => + expect( + page.getByText('Access is limited to the rules below.'), + ).toBeVisible(), + ); +}); + +it('disables adding and removing rules while the marketplace is open to everyone', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + + // Open-to-everyone means the scoped rules are preserved but inactive: no add, no delete. + expect(page.getByRole('button', { name: 'Add rule' })).toBeDisabled(); + expect(page.getByTestId('DeleteIconButton')).toBeDisabled(); +}); diff --git a/client/app/routers/courseless/systemAdmin.tsx b/client/app/routers/courseless/systemAdmin.tsx index 79edf10de6f..2e1bba29aac 100644 --- a/client/app/routers/courseless/systemAdmin.tsx +++ b/client/app/routers/courseless/systemAdmin.tsx @@ -67,6 +67,17 @@ const systemAdminRouter: Translated = (_) => ({ ).default, }), }, + { + path: 'marketplace_allowlist_rules', + lazy: async (): Promise> => ({ + Component: ( + await import( + /* webpackChunkName: 'MarketplaceAllowlistIndex' */ + 'bundles/system/admin/admin/pages/MarketplaceAllowlistIndex' + ) + ).default, + }), + }, { path: 'get_help', lazy: async (): Promise> => ({ diff --git a/client/app/types/system/marketplaceAllowlist.ts b/client/app/types/system/marketplaceAllowlist.ts new file mode 100644 index 00000000000..057ac4aba1c --- /dev/null +++ b/client/app/types/system/marketplaceAllowlist.ts @@ -0,0 +1,18 @@ +export type AllowlistRuleType = 'user' | 'instance' | 'email_domain'; + +export interface AllowlistRuleData { + id: number; + ruleType: AllowlistRuleType; + userId: number | null; + userName: string | null; + instanceId: number | null; + instanceName: string | null; + emailDomain: string | null; +} + +export interface AllowlistRuleFormData { + ruleType: AllowlistRuleType; + userId?: number; + instanceId?: number; + emailDomain?: string; +} diff --git a/config/routes.rb b/config/routes.rb index 995ee5fea0d..182e0025638 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -109,6 +109,7 @@ get '/' => 'admin#index' get 'deployment_info' => 'admin#deployment_info' resources :announcements, only: [:index, :create, :update, :destroy] + resources :marketplace_allowlist_rules, only: [:index, :create, :destroy] resources :instances, only: [:index, :create, :update, :destroy] resources :users, only: [:index, :update, :destroy] resources :courses, only: [:index, :destroy] diff --git a/db/migrate/20260707000003_create_course_assessment_marketplace_allowlist_rules.rb b/db/migrate/20260707000003_create_course_assessment_marketplace_allowlist_rules.rb new file mode 100644 index 00000000000..dccb44bd76c --- /dev/null +++ b/db/migrate/20260707000003_create_course_assessment_marketplace_allowlist_rules.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +class CreateCourseAssessmentMarketplaceAllowlistRules < ActiveRecord::Migration[7.2] + def change + create_table :course_assessment_marketplace_allowlist_rules do |t| + t.integer :rule_type, null: false + t.references :user, foreign_key: { to_table: :users }, null: true, index: true + t.references :instance, foreign_key: true, null: true, index: true + t.string :email_domain, null: true + t.timestamps + end + add_index :course_assessment_marketplace_allowlist_rules, :email_domain + end +end diff --git a/db/migrate/20260716000001_add_everyone_uniqueness_to_marketplace_allowlist_rules.rb b/db/migrate/20260716000001_add_everyone_uniqueness_to_marketplace_allowlist_rules.rb new file mode 100644 index 00000000000..8d65d902ffb --- /dev/null +++ b/db/migrate/20260716000001_add_everyone_uniqueness_to_marketplace_allowlist_rules.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +class AddEveryoneUniquenessToMarketplaceAllowlistRules < ActiveRecord::Migration[7.2] + def change + # `everyone` is rule_type == 3 (enum below). A partial unique index enforces at most one such row, + # the DB-level backstop for the model's `uniqueness` validation (repo rule: pair the two). + add_index :course_assessment_marketplace_allowlist_rules, :rule_type, + unique: true, where: 'rule_type = 3', + name: 'index_marketplace_allowlist_rules_one_everyone' + end +end diff --git a/db/schema.rb b/db/schema.rb index d230ffdb8ac..9330c5aa1ff 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_07_07_000002) do +ActiveRecord::Schema[7.2].define(version: 2026_07_16_000001) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -286,6 +286,19 @@ t.index ["updater_id"], name: "fk__cama_updater_id" end + create_table "course_assessment_marketplace_allowlist_rules", force: :cascade do |t| + t.integer "rule_type", null: false + t.bigint "user_id" + t.bigint "instance_id" + t.string "email_domain" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email_domain"], name: "idx_on_email_domain_6577b88d4e" + t.index ["instance_id"], name: "idx_on_instance_id_77af5cff27" + t.index ["rule_type"], name: "index_marketplace_allowlist_rules_one_everyone", unique: true, where: "(rule_type = 3)" + t.index ["user_id"], name: "index_course_assessment_marketplace_allowlist_rules_on_user_id" + end + create_table "course_assessment_marketplace_listings", force: :cascade do |t| t.bigint "assessment_id", null: false t.boolean "published", default: false, null: false @@ -1956,6 +1969,8 @@ add_foreign_key "course_assessment_marketplace_adoptions", "courses", column: "destination_course_id", name: "fk_cama_destination_course_id", on_delete: :cascade add_foreign_key "course_assessment_marketplace_adoptions", "users", column: "creator_id", name: "fk_course_assessment_marketplace_adoptions_creator_id" add_foreign_key "course_assessment_marketplace_adoptions", "users", column: "updater_id", name: "fk_course_assessment_marketplace_adoptions_updater_id" + add_foreign_key "course_assessment_marketplace_allowlist_rules", "instances" + add_foreign_key "course_assessment_marketplace_allowlist_rules", "users" add_foreign_key "course_assessment_marketplace_listings", "course_assessments", column: "assessment_id", name: "fk_course_assessment_marketplace_listings_assessment_id", on_delete: :cascade add_foreign_key "course_assessment_marketplace_listings", "users", column: "creator_id", name: "fk_course_assessment_marketplace_listings_creator_id" add_foreign_key "course_assessment_marketplace_listings", "users", column: "publisher_id", name: "fk_course_assessment_marketplace_listings_publisher_id" diff --git a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb index 008b73b548b..72757b923f0 100644 --- a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb @@ -12,6 +12,8 @@ before { controller_sign_in(controller, manager.user) } describe 'GET #index' do + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) } + let!(:published) { create(:course_assessment_marketplace_listing, published: true) } let!(:unpublished) { create(:course_assessment_marketplace_listing, published: false) } @@ -69,12 +71,79 @@ end end end + describe 'GET #index visibility gate' do + subject { get :index, params: { course_id: course.id, format: :json } } + + # The suite runs with `use_transactional_fixtures = false` (see spec/rails_helper.rb), so rows + # persist across examples/runs. `:everyone` is a DB-enforced singleton (one row allowed), so any + # leftover row here would either block a later `create(:everyone)` with a uniqueness error or + # spuriously widen access in a sibling example. Mirrors the cleanup in + # spec/models/course/assessment/marketplace/allowlist_rule_spec.rb. + before { Course::Assessment::Marketplace::AllowlistRule.delete_all } + + context 'when the manager is not on the allow-list' do + before { controller_sign_in(controller, manager.user) } + + it 'denies access' do + expect { subject }.to raise_exception(CanCan::AccessDenied) + end + end + + context 'when an allow-list rule matches the manager' do + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) + controller_sign_in(controller, manager.user) + end + + it 'permits access' do + expect { subject }.not_to raise_exception + end + end + + context 'when the user is a system administrator' do + let(:admin) { create(:administrator) } + before do + create(:course_manager, course: course, user: admin) + controller_sign_in(controller, admin) + end + + it 'permits access without an allow-list rule' do + expect { subject }.not_to raise_exception + end + end + + context "when an 'everyone' rule exists" do + before do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + controller_sign_in(controller, manager.user) + end + + it 'permits a manager who has no matching scoped rule' do + expect { subject }.not_to raise_exception + end + end + + context "when an 'everyone' rule exists but the user is a student" do + let(:student) { create(:course_student, course: course).user } + before do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + controller_sign_in(controller, student) + end + + it 'still denies a non-manager (the manager gate holds)' do + expect { subject }.to raise_exception(CanCan::AccessDenied) + end + end + end + describe 'POST #duplicate' do # `have_enqueued_job` requires the :test adapter; the test env defaults to :background_thread. # `run_rescue` re-enables handle_access_denied so AccessDenied renders 403 rather than # propagating (controller specs bypass_rescue by default — see spec/support/controller_exceptions.rb). run_rescue + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) } + with_active_job_queue_adapter(:test) do let!(:listing) { create(:course_assessment_marketplace_listing, published: true) } let!(:tab) { course.assessment_categories.first.tabs.first } @@ -116,6 +185,8 @@ # propagating (controller specs bypass_rescue by default — see spec/support/controller_exceptions.rb). run_rescue + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) } + let!(:listing) do assessment = create(:assessment, course: create(:course)) create(:course_assessment_question_multiple_response, :multiple_choice, assessment: assessment) @@ -161,6 +232,7 @@ ActsAsTenant.with_tenant(home_instance) do course = create(:course) manager = create(:course_manager, course: course) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) controller_sign_in(controller, manager.user) # Point the request at the home instance's host so `deduce_tenant` resolves it (this # describe is outside `with_tenant`, which would otherwise set the host header for us). diff --git a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb index e679abb421f..c574c01cc99 100644 --- a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb @@ -35,7 +35,10 @@ let(:destination_course) { create(:course) } let(:manager) { create(:course_manager, course: destination_course).user } - before { controller_sign_in(controller, manager) } + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager) + controller_sign_in(controller, manager) + end it 'serializes the question across instances' do get :show, as: :json, params: { diff --git a/spec/controllers/course/assessment_marketplace_component_spec.rb b/spec/controllers/course/assessment_marketplace_component_spec.rb index 6fa1c1f6aab..78cd6db53f5 100644 --- a/spec/controllers/course/assessment_marketplace_component_spec.rb +++ b/spec/controllers/course/assessment_marketplace_component_spec.rb @@ -15,7 +15,10 @@ context 'when the user can access the marketplace (course manager)' do let(:user) { create(:course_manager, course: course).user } - before { controller_sign_in(controller, user) } + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + controller_sign_in(controller, user) + end it 'exposes an admin sidebar item pointing at the marketplace' do item = subject.sidebar_items.find { |i| i[:key] == :admin_marketplace } diff --git a/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb b/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb new file mode 100644 index 00000000000..60533e8350a --- /dev/null +++ b/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe System::Admin::MarketplaceAllowlistRulesController, type: :controller do + let!(:instance) { Instance.default } + + with_tenant(:instance) do + let(:admin) { create(:administrator) } + before { controller_sign_in(controller, admin) } + + describe 'POST #create' do + subject do + post :create, format: :json, params: { + allowlist_rule: { rule_type: 'email_domain', email_domain: 'schools.gov.sg' } + } + end + + it 'creates an email-domain rule' do + expect { subject }. + to change { Course::Assessment::Marketplace::AllowlistRule.count }.by(1) + expect(response).to have_http_status(:ok) + end + end + + describe 'GET #index' do + render_views + # This suite runs with `use_transactional_fixtures = false` and no DatabaseCleaner, so rows + # created by earlier local runs of this factory persist in the dev/test DB; scope to a clean + # slate here so the size assertion below is deterministic. + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain) + end + + it 'lists the rules' do + get :index, format: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body['rules'].size).to eq(1) + end + end + + describe 'GET #index everyone-mode reporting' do + render_views + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain) + end + + it 'reports everyoneRuleId null and lists only scoped rules when no everyone rule exists' do + get :index, format: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body['everyoneRuleId']).to be_nil + expect(response.parsed_body['rules'].size).to eq(1) + end + + it 'reports everyoneRuleId and excludes the everyone rule from the list' do + everyone = create(:course_assessment_marketplace_allowlist_rule, :everyone) + get :index, format: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body['everyoneRuleId']).to eq(everyone.id) + expect(response.parsed_body['rules'].map { |r| r['ruleType'] }).not_to include('everyone') + expect(response.parsed_body['rules'].size).to eq(1) + end + end + + describe "POST #create with rule_type 'everyone'" do + before { Course::Assessment::Marketplace::AllowlistRule.delete_all } + + it 'opens the marketplace to everyone' do + expect do + post :create, format: :json, params: { allowlist_rule: { rule_type: 'everyone' } } + end.to change { Course::Assessment::Marketplace::AllowlistRule.rule_type_everyone.count }.by(1) + expect(response).to have_http_status(:ok) + end + + it 'rejects a second everyone rule' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + expect do + post :create, format: :json, params: { allowlist_rule: { rule_type: 'everyone' } } + end.not_to(change { Course::Assessment::Marketplace::AllowlistRule.count }) + expect(response).to have_http_status(:bad_request) + end + + it 'surfaces the uniqueness error when rejected' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + post :create, format: :json, params: { allowlist_rule: { rule_type: 'everyone' } } + expect(response.parsed_body['errors']).to include('already been taken') + end + end + + describe 'DELETE #destroy' do + let!(:rule) { create(:course_assessment_marketplace_allowlist_rule, :for_email_domain) } + + it 'removes the rule' do + expect { delete :destroy, format: :json, params: { id: rule.id } }. + to change { Course::Assessment::Marketplace::AllowlistRule.count }.by(-1) + expect(response).to have_http_status(:ok) + end + end + + describe 'authorization' do + run_rescue + + it 'forbids a non-administrator' do + controller_sign_in(controller, create(:user)) + get :index, format: :json + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/factories/course_assessment_marketplace_allowlist_rules.rb b/spec/factories/course_assessment_marketplace_allowlist_rules.rb new file mode 100644 index 00000000000..3ff9e5f0980 --- /dev/null +++ b/spec/factories/course_assessment_marketplace_allowlist_rules.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_allowlist_rule, + class: 'Course::Assessment::Marketplace::AllowlistRule' do + trait :for_user do + rule_type { :user } + association :user + end + trait :for_instance do + rule_type { :instance } + association :instance + end + trait :for_email_domain do + rule_type { :email_domain } + email_domain { 'schools.gov.sg' } + end + trait :everyone do + rule_type { :everyone } + end + end +end diff --git a/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb new file mode 100644 index 00000000000..29bc7c613ff --- /dev/null +++ b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::AllowlistRule, type: :model do + let!(:instance) { Instance.default } + + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + User::Email.delete_all + end + + with_tenant(:instance) do + describe 'validations' do + it 'requires user for a user rule' do + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: nil) + expect(rule).not_to be_valid + expect(rule.errors[:user]).to be_present + end + + it 'requires email_domain for an email_domain rule' do + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: nil) + expect(rule).not_to be_valid + expect(rule.errors[:email_domain]).to be_present + end + + it 'requires instance for an instance rule' do + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :instance, instance: nil) + expect(rule).not_to be_valid + expect(rule.errors[:instance]).to be_present + end + + it 'is valid as an everyone rule with no target fields' do + rule = build(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(rule).to be_valid + end + + it 'allows only one everyone rule' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + duplicate = build(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:rule_type]).to be_present + end + end + + describe '.grants_access?' do + it 'is false for a nil user' do + expect(described_class.grants_access?(nil)).to be(false) + end + + it 'is false when no rule matches' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'nomatch.example') + expect(described_class.grants_access?(user)).to be(false) + end + + it 'matches an explicit user rule' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + expect(described_class.grants_access?(user)).to be(true) + end + + it 'matches an instance rule when the user belongs to that instance' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :instance, instance: instance) + expect(described_class.grants_access?(user)).to be(true) + end + + it 'does not match an instance rule for another instance under the current tenant' do + user = create(:user) + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) { InstanceUser.create!(user: user) } + create(:course_assessment_marketplace_allowlist_rule, rule_type: :instance, + instance: other_instance) + # `user.instance_users` is tenant-scoped (acts_as_tenant), so an instance rule grants + # access only while browsing the allow-listed instance — membership elsewhere is invisible. + expect(described_class.grants_access?(user)).to be(false) + end + + it 'matches an email-domain rule case-insensitively' do + user_email = create(:user_email, email: 'testuser@Schools.GOV.sg') + user = user_email.user + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + expect(described_class.grants_access?(user)).to be(true) + end + + it 'does not match a different email domain' do + user_email = create(:user_email, email: 'testuser@other.edu') + user = user_email.user + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + expect(described_class.grants_access?(user)).to be(false) + end + + it 'matches any user when an everyone rule exists' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(described_class.grants_access?(user)).to be(true) + end + + it 'is false for a nil user even when an everyone rule exists' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(described_class.grants_access?(nil)).to be(false) + end + end + end +end diff --git a/spec/models/course/assessment_marketplace_ability_spec.rb b/spec/models/course/assessment_marketplace_ability_spec.rb index 1dabaf126c8..cd30100aa7c 100644 --- a/spec/models/course/assessment_marketplace_ability_spec.rb +++ b/spec/models/course/assessment_marketplace_ability_spec.rb @@ -19,6 +19,7 @@ context 'when the user is a course manager' do let(:course_user) { create(:course_manager, course: course) } let(:user) { course_user.user } + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) } it { is_expected.to be_able_to(:access_marketplace, course) } it { is_expected.not_to be_able_to(:publish_to_marketplace, build(:assessment)) }