diff --git a/app/assets/stylesheets/dsfr-extensions.css b/app/assets/stylesheets/dsfr-extensions.css index 113711bb07..03e59f0dd3 100644 --- a/app/assets/stylesheets/dsfr-extensions.css +++ b/app/assets/stylesheets/dsfr-extensions.css @@ -1,3 +1,37 @@ +/* DSFR scope les variantes de couleur des tags aux / + +
  • + +
  • + +
    + <%= rendered %> +
    +
    +
    <%= source %>
    +
    + diff --git a/app/views/instruction/cas_usages/show.html.erb b/app/views/instruction/cas_usages/show.html.erb new file mode 100644 index 0000000000..1c19efee8b --- /dev/null +++ b/app/views/instruction/cas_usages/show.html.erb @@ -0,0 +1,421 @@ +<% set_title! t('page_titles.instruction.cas_usages_show', cas_usage_name: @cas_usage.name_with_service_provider, provider_name: @data_provider.name) %> + +<%= render 'instruction/data_providers/header' %> + +
    +

    + <%= link_to t('.back'), + instruction_formulaire_path(provider_slug: @data_provider.slug, id: @formulaire.id), + class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left' %> +

    + +

    + <%= @cas_usage.name_with_service_provider %> + <% if @cas_usage.default %> + + <%= t('.default_badge') %> + + <% end %> + <% if @cas_usage.inherited?(:name) %> + + <%= t('.inherited_badge') %> + + <% end %> +

    +

    + <%= t('.sections.identification') %> : + <%= @cas_usage.uid %> + — <%= t('instruction.formulaires.show.title') %> : + <%= link_to @formulaire.name_with_stage, + instruction_formulaire_path(provider_slug: @data_provider.slug, id: @formulaire.id), + class: 'fr-link' %> +

    + +
    +

    + <%= t('.sections.description') %> + <% if @cas_usage.inherited?(:description) %> + <%= t('.inherited_badge') %> + <% end %> +

    + <% if @cas_usage.description.present? %> + <%= render 'rendered_html_tabs', + id_prefix: 'description', + rendered: sanitize(@cas_usage.description, tags: %w[strong em b i u br a p ul li]), + source: @cas_usage.description %> + <% else %> +

    <%= t('.empty_description') %>

    + <% end %> +
    + +
    +

    + <%= t('.sections.introduction') %> + <% if @cas_usage.inherited?(:introduction) %> + <%= t('.inherited_badge') %> + <% end %> +

    + <% if @cas_usage.introduction.present? %> + <%= render 'rendered_html_tabs', + id_prefix: 'introduction', + rendered: sanitize(@cas_usage.introduction, tags: %w[strong em b i u br a p ul li div]), + source: @cas_usage.introduction %> + <% else %> +

    <%= t('.empty_introduction') %>

    + <% end %> +
    + +
    +

    <%= t('.sections.flags') %>

    +
    + + + + + + + + + + + + + + + + + + + + + +
    PropriétéValeur
    <%= t('.flags.public') %><%= t(".#{@cas_usage.public}") %>
    <%= t('.flags.startable_by_applicant') %><%= t(".#{@cas_usage.startable_by_applicant}") %>
    + <%= t('.flags.single_page_view') %> + + + + + <% if @cas_usage.single_page_view.present? %> + <%= @cas_usage.single_page_view %> + <% else %> + <%= t('.flags.single_page_view_default') %> + <% end %> +
    +
    +
    + +
    +

    + <%= t('.sections.flow') %> + <% if @cas_usage.inherited?(:steps) %> + <%= t('.inherited_badge') %> + <% end %> +

    + +
    +
      +
    • + +
    • +
    • + +
    • +
    + +
    + <% catalog_names = @formulaire.blocks.map { |b| b[:name] || b['name'] } %> + <% static_names = @cas_usage.static_blocks.map { |b| b[:name] || b['name'] }.to_set %> + <% step_names = @cas_usage.steps.map { |s| s[:name] || s['name'] }.to_set %> + <% catalog_items = catalog_names.map { |n| [n, static_names.include?(n) ? :static : :editable, false] } %> + <% extras = ((static_names | step_names) - catalog_names.to_set).map { |n| [n, static_names.include?(n) ? :static : :editable, true] } %> + <% ordered_items = catalog_items + extras.to_a %> + + <% mode_label = @cas_usage.steps.empty? ? :single_page : :multi_step %> +

    + <%= t(".flow.mode.#{mode_label}_intro") %> + <% if mode_label == :single_page %> + <%= t('.flow.single_page_badge') %> + <%= @cas_usage.single_page_view.presence || @cas_usage.uid.underscore %> + <% end %> +

    + + <% if ordered_items.any? %> +
      + <% ordered_items.each_with_index do |(name, state, out_of_catalog), idx| %> +
    1. + <%= idx + 1 %> + <% if state == :static %> + + <%= name %> + + <% else %> + <%= name %> + <% end %> + <% if out_of_catalog %> + + <%= t('.flow.out_of_catalog') %> + + <% end %> +
    2. + <% end %> +
    + <% else %> +

    <%= t('.flow.empty_catalog') %>

    + <% end %> +
    + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    <%= t('.flow.tabs.data_column') %><%= t('.flow.tabs.data_value') %>
    + formulaire.blocks + + + + + <% catalog = @formulaire.blocks.map { |b| b[:name] || b['name'] } %> + <% if catalog.any? %> +
      + <% catalog.each do |name| %> +
    1. <%= name %>
    2. + <% end %> +
    + <% else %> + <%= t('.flow.tabs.empty_array') %> + <% end %> +
    steps + <% step_names = @cas_usage.steps.map { |s| s[:name] || s['name'] } %> + <% if step_names.any? %> +
      + <% step_names.each do |name| %> +
    1. <%= name %>
    2. + <% end %> +
    + <% else %> + <%= t('.flow.tabs.empty_array') %> + <% end %> +
    static_blocks + <% block_names = @cas_usage.static_blocks.map { |b| b[:name] || b['name'] } %> + <% if block_names.any? %> +
      + <% block_names.each do |name| %> +
    • <%= name %>
    • + <% end %> +
    + <% else %> + <%= t('.flow.tabs.empty_array') %> + <% end %> +
    single_page_view + <% if @cas_usage.single_page_view.present? %> + <%= @cas_usage.single_page_view %> + <% else %> + nil + <% end %> +
    +
    +
    +
    +
    + +
    +

    <%= t('.sections.scopes_config') %>

    + <% if @formulaire.scopes.blank? %> +

    <%= t('.empty_scopes_config') %>

    + <% else %> + <% disabled_set = (@cas_usage.scopes_config[:disabled] || []).to_set %> + <% hidden_set = (@cas_usage.scopes_config[:hide] || []).to_set %> + <% displayed_whitelist = @cas_usage.scopes_config[:displayed].presence %> + <% preset_set = (@cas_usage.initialize_with[:scopes] || []).to_set %> + +
    +
      +
    • + +
    • +
    • + +
    • +
    + +
    +

    <%= t('.scopes_config_intro') %>

    +
      +
    • <%= t('.scope_states.displayed') %>
    • +
    • <%= t('.scope_states.disabled') %>
    • +
    • <%= t('.scope_states.hidden') %>
    • +
    • + + + <%= t('.scope_states.preset') %> + +
    • +
    + + <% @formulaire.scopes.group_by(&:group).each do |group_name, group_scopes| %> +
    + <% if group_name.present? %> +

    <%= group_name %>

    + <% end %> +
      + <% group_scopes.each do |scope| %> + <% state = if hidden_set.include?(scope.value) || (displayed_whitelist && !displayed_whitelist.include?(scope.value)) + :hidden + elsif disabled_set.include?(scope.value) + :disabled + else + :displayed + end %> + <% classes, style = case state + when :displayed then ['fr-tag fr-icon-eye-line fr-tag--icon-left', nil] + when :disabled then ['fr-tag fr-tag--brown-cafe-creme fr-icon-lock-line fr-tag--icon-left', nil] + when :hidden then ['fr-tag fr-icon-eye-off-line fr-tag--icon-left', 'opacity: 0.55;'] + end %> +
    • + + <% if preset_set.include?(scope.value) %> + + <% end %> + <%= scope.name.presence || scope.value %> + +
    • + <% end %> +
    +
    + <% end %> +
    + +
    + <% code_data = { + 'scopes_config' => @cas_usage.scopes_config.deep_stringify_keys, + 'preselected_scopes' => (@cas_usage.initialize_with[:scopes] || []), + } %> +
    <%= code_data.to_yaml(line_width: -1).sub(/\A---\n/, '') %>
    + +

    <%= t('.formulaire_scopes_intro') %>

    + <% formulaire_scopes_dump = { + 'formulaire_scopes' => @formulaire.scopes.map { |s| + { + 'name' => s.name, + 'value' => s.value, + 'group' => s.group, + 'link' => s.link, + 'included' => s.included? || nil, + 'disabled' => s.disabled? || nil, + 'deprecated' => (s.deprecated_date if s.deprecated?), + }.compact + }, + } %> +
    <%= formulaire_scopes_dump.to_yaml(line_width: -1).sub(/\A---\n/, '') %>
    +
    +
    + <% end %> +
    + + <% initial_data = @cas_usage.initialize_with.except(:scopes) %> +
    +

    <%= t('.sections.initial_data') %>

    + <% if initial_data.blank? %> +

    <%= t('.empty_initial_data') %>

    + <% else %> +
    +
      +
    • + +
    • +
    • + +
    • +
    + +
    +

    <%= t('.initial_data_tabs.form_intro') %>

    + <% initial_data.each do |key, value| %> + <% label = t("activerecord.attributes.authorization_request.#{key}", default: key.to_s.humanize) %> + <% input_id = "initial-#{@cas_usage.uid}-#{key}".parameterize %> +
    + + <% if key.to_s == 'modalities' && value.is_a?(Array) %> +
      + <% value.each do |modality| %> +
    • <%= modality %>
    • + <% end %> +
    + <% else %> + <% case value + when String %> + <% if value.match?(%r{\Ahttps?://}) %> + + <% elsif value.include?("\n") || value.length > 80 %> + + <% else %> + + <% end %> + <% when TrueClass, FalseClass %> + " readonly> + <% when Numeric %> + + <% else %> +
    <%= JSON.pretty_generate(value) %>
    + <% end %> + <% end %> +
    + <% end %> +
    + +
    +
    <%= initial_data.deep_stringify_keys.to_yaml(line_width: -1).sub(/\A---\n/, '') %>
    +
    +
    + <% end %> +
    + +
    +

    <%= t('.sections.service_provider') %>

    + <% if @cas_usage.service_provider %> +

    + <%= @cas_usage.service_provider.name %> + <% if @cas_usage.service_provider.try(:editor?) %> + éditeur + <% end %> +

    + <% else %> +

    <%= t('.no_service_provider') %>

    + <% end %> +
    +
    diff --git a/app/views/instruction/data_providers/_header.html.erb b/app/views/instruction/data_providers/_header.html.erb new file mode 100644 index 0000000000..04302ea918 --- /dev/null +++ b/app/views/instruction/data_providers/_header.html.erb @@ -0,0 +1,32 @@ +
    +
    +
    + <% if @data_provider.logo.attached? %> +
    + <%= image_tag @data_provider.logo, alt: "#{@data_provider.name}, fournisseur de données", style: 'max-height: 80px; max-width: 160px;' %> +
    + <% end %> +
    +

    + <%= link_to t('instruction.data_providers.title'), + instruction_data_providers_path, + class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left' %> +

    +

    + <%= link_to @data_provider.name, + instruction_formulaires_path(provider_slug: @data_provider.slug), + class: 'fr-link fr-link--lg' %> +

    + <% if @data_provider.link.present? %> +

    + <%= link_to @data_provider.link, @data_provider.link, + target: '_blank', + rel: 'noopener external', + class: 'fr-link', + title: "#{@data_provider.name} - #{t('shared.new_window')}" %> +

    + <% end %> +
    +
    +
    +
    diff --git a/app/views/instruction/data_providers/index.html.erb b/app/views/instruction/data_providers/index.html.erb new file mode 100644 index 0000000000..5c305eae60 --- /dev/null +++ b/app/views/instruction/data_providers/index.html.erb @@ -0,0 +1,51 @@ +<% set_title! t('page_titles.instruction.data_providers_index') %> + +
    +
    +

    <%= t('instruction.data_providers.title') %>

    +

    <%= t('.title') %>

    +
    +
    + +
    +

    <%= t('.subtitle') %>

    + + <% if @data_providers.empty? %> +

    <%= t('.empty') %>

    + <% else %> +
    + <% @data_providers.each do |data_provider| %> +
    + +
    + <% end %> +
    + <% end %> +
    diff --git a/app/views/instruction/formulaires/index.html.erb b/app/views/instruction/formulaires/index.html.erb new file mode 100644 index 0000000000..981bd926ac --- /dev/null +++ b/app/views/instruction/formulaires/index.html.erb @@ -0,0 +1,39 @@ +<% set_title! t('page_titles.instruction.formulaires_index', provider_name: @data_provider.name) %> + +<%= render 'instruction/data_providers/header' %> + +
    +

    <%= t('.title') %>

    +

    + <%= t('.subtitle', provider_name: @data_provider.name) %> +

    + + <% if @formulaires.empty? %> +

    <%= t('.empty') %>

    + <% else %> +
    + <% @formulaires.each do |formulaire| %> +
    + +
    + <% end %> +
    + <% end %> +
    diff --git a/app/views/instruction/formulaires/show.html.erb b/app/views/instruction/formulaires/show.html.erb new file mode 100644 index 0000000000..ff39b5b843 --- /dev/null +++ b/app/views/instruction/formulaires/show.html.erb @@ -0,0 +1,203 @@ +<% set_title! t('page_titles.instruction.formulaires_show', formulaire_name: @formulaire.name, provider_name: @data_provider.name) %> + +<%= render 'instruction/data_providers/header' %> + +
    +

    + <%= link_to t('.back'), + instruction_formulaires_path(provider_slug: @data_provider.slug), + class: 'fr-link fr-icon-arrow-left-line fr-link--icon-left' %> +

    + +

    <%= @formulaire.name_with_stage %>

    +

    + <%= t('.sections.identification') %> : + <%= @formulaire.id %> +

    + + <% if @formulaire.description.present? %> +
    +

    <%= t('.sections.description') %>

    +

    <%= @formulaire.description %>

    +
    + <% end %> + +
    +

    <%= t('.sections.identity') %>

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    <%= t('.fields.label') %><%= t('.fields.value') %>
    <%= t('.fields.kind') %><%= @formulaire.kind.presence || content_tag(:span, t('.empty_value'), class: 'fr-text-mention--grey') %>
    <%= t('.fields.link') %> + <% if @formulaire.link.present? %> + <%= link_to @formulaire.link, @formulaire.link, class: 'fr-link', target: '_blank', rel: 'noopener' %> + <% else %> + <%= t('.empty_value') %> + <% end %> +
    <%= t('.fields.access_link') %> + <% if @formulaire.access_link.present? %> + <%= @formulaire.access_link %> + <% else %> + <%= t('.empty_value') %> + <% end %> +
    <%= t('.fields.cgu_link') %> + <% if @formulaire.cgu_link.present? %> + <%= link_to @formulaire.cgu_link, @formulaire.cgu_link, class: 'fr-link', target: '_blank', rel: 'noopener' %> + <% else %> + <%= t('.empty_value') %> + <% end %> +
    <%= t('.fields.support_email') %> + <% if @formulaire.support_email.present? %> + <%= mail_to @formulaire.support_email, @formulaire.support_email, class: 'fr-link' %> + <% else %> + <%= t('.empty_value') %> + <% end %> +
    +
    +
    + +
    +

    <%= t('.sections.flags') %>

    +
    + + + + + + + + + + + + + + + + + + + + + +
    <%= t('.fields.label') %><%= t('.fields.value') %>
    <%= t('.flags.public') %><%= t(".#{@formulaire.public}") %>
    <%= t('.flags.startable_by_applicant') %><%= t(".#{@formulaire.startable_by_applicant}") %>
    <%= t('.flags.unique') %><%= t(".#{@formulaire.unique}") %>
    +
    +
    + + <% if @formulaire.features.present? %> +
    +

    <%= t('.sections.features') %>

    +
      + <% @formulaire.features.each do |name, value| %> +
    • + <%= name %> : <%= t(".#{value}", default: value.to_s) %> +
    • + <% end %> +
    +
    + <% end %> + +
    +

    <%= t('.sections.scopes') %>

    + <% if @formulaire.scopes.any? %> +
      + <% @formulaire.scopes.each do |scope| %> +
    • + <%= scope.name.presence || scope.value %> +
    • + <% end %> +
    + <% else %> +

    <%= t('.empty_scopes') %>

    + <% end %> +
    + +
    +

    <%= t('.sections.blocks') %>

    + <% if @formulaire.blocks.any? %> +
      + <% @formulaire.blocks.each do |block| %> +
    1. <%= block[:name] || block['name'] %>
    2. + <% end %> +
    + <% else %> +

    <%= t('.empty_blocks') %>

    + <% end %> +
    + +
    +

    <%= t('.sections.cas_usages') %>

    + <% if @cas_usages.any? %> +
    + <% @cas_usages.each do |cas_usage| %> + <% demandes_count = @demandes_counts.fetch(cas_usage.uid, 0) %> + <% habilitations_count = @habilitations_counts.fetch(cas_usage.uid, 0) %> +
    + +
    + <% end %> +
    + <% else %> +

    <%= t('.empty_cas_usages') %>

    + <% end %> +
    +
    diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 0135c9b695..3b1c8f3a6c 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -375,6 +375,72 @@ 77 ], "note": "" + }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 4, + "fingerprint": "ed6016dbadb7d19f8beaf38d41e372bcac56d61ce4db2889a8cff7af9581b5c1", + "check_name": "LinkToHref", + "message": "Potentially unsafe model attribute in `link_to` href", + "file": "app/views/instruction/formulaires/show.html.erb", + "line": 44, + "link": "https://brakemanscanner.org/docs/warning_types/link_to_href", + "code": "link_to(AuthorizationDefinition.find(params.expect(:id)).link, AuthorizationDefinition.find(params.expect(:id)).link, :class => \"fr-link\", :target => \"_blank\", :rel => \"noopener\")", + "render_path": null, + "location": { + "type": "template", + "template": "instruction/formulaires/show" + }, + "user_input": "AuthorizationDefinition.find(params.expect(:id)).link", + "confidence": "Weak", + "cwe_id": [ + 79 + ], + "note": "URL admin-controlled (YAML-defined), model-validated" + }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 4, + "fingerprint": "0d0bf8febd436e250d1f4dea47e04fd3663cd6be5e8140b77f89051661a4a801", + "check_name": "LinkToHref", + "message": "Potentially unsafe model attribute in `link_to` href", + "file": "app/views/instruction/formulaires/show.html.erb", + "line": 64, + "link": "https://brakemanscanner.org/docs/warning_types/link_to_href", + "code": "link_to(AuthorizationDefinition.find(params.expect(:id)).cgu_link, AuthorizationDefinition.find(params.expect(:id)).cgu_link, :class => \"fr-link\", :target => \"_blank\", :rel => \"noopener\")", + "render_path": null, + "location": { + "type": "template", + "template": "instruction/formulaires/show" + }, + "user_input": "AuthorizationDefinition.find(params.expect(:id)).cgu_link", + "confidence": "Weak", + "cwe_id": [ + 79 + ], + "note": "URL admin-controlled (YAML-defined), model-validated" + }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 4, + "fingerprint": "89409e5c92e382dad896ef2282fcc8e80035b732567281f4e882b890a03640f7", + "check_name": "LinkToHref", + "message": "Potentially unsafe model attribute in `link_to` href", + "file": "app/views/instruction/data_providers/_header.html.erb", + "line": 22, + "link": "https://brakemanscanner.org/docs/warning_types/link_to_href", + "code": "link_to(DataProvider.friendly.find(params.expect(:provider_slug)).link, DataProvider.friendly.find(params.expect(:provider_slug)).link, :target => \"_blank\", :rel => \"noopener external\", :class => \"fr-link\", :title => (\"#{DataProvider.friendly.find(params.expect(:provider_slug)).name} - #{t(\"shared.new_window\")}\"))", + "render_path": null, + "location": { + "type": "template", + "template": "instruction/data_providers/_header" + }, + "user_input": "DataProvider.friendly.find(params.expect(:provider_slug)).link", + "confidence": "Weak", + "cwe_id": [ + 79 + ], + "note": "URL admin-controlled (YAML-defined), model-validated" } ], "brakeman_version": "7.1.1" diff --git a/config/locales/activerecord.fr.yml b/config/locales/activerecord.fr.yml index 8f1d2d969e..3d4873ab7b 100644 --- a/config/locales/activerecord.fr.yml +++ b/config/locales/activerecord.fr.yml @@ -182,6 +182,16 @@ fr: habilitation_type/kind/values: api: API service: Service + form_template: + name: Nom du cas d’usage + description: Description + introduction: Introduction + use_case: Identifiant du cas d’usage + default: Cas d’usage par défaut + public: Public + startable_by_applicant: Demande initiable par le demandeur + single_page_view: Vue en une seule page + service_provider_id: Fournisseur de service instructor_draft_request: applicant_email: Adresse email du demandeur organization_siret: Numéro de SIRET de l'organisation @@ -275,6 +285,17 @@ fr: scope_name_blank: "ne peut pas être vide" scope_value_blank: "ne peut pas être vide" scope_value_duplicate: "est déjà utilisée par un autre scope" + form_template: + attributes: + base: + last_default_form_template: "ne peut pas être supprimé : il doit rester au moins un template par défaut pour ce type d’habilitation" + slug: + taken_by_yaml_form: "est déjà utilisé par un formulaire YAML" + default: + last_default_form_template: "ne peut pas être désactivé : il doit rester au moins un template par défaut pour ce type d’habilitation" + already_taken: "un autre template par défaut existe déjà pour ce type d’habilitation" + service_provider_id: + inclusion: "n’existe pas dans la liste des fournisseurs de service" impersonation: attributes: user: diff --git a/config/locales/instruction_catalogue.fr.yml b/config/locales/instruction_catalogue.fr.yml new file mode 100644 index 0000000000..01800ba6ae --- /dev/null +++ b/config/locales/instruction_catalogue.fr.yml @@ -0,0 +1,129 @@ +fr: + instruction: + data_providers: + title: Espace fournisseur de données + index: + title: Fournisseurs de données + subtitle: Sélectionnez un fournisseur pour consulter ses formulaires et cas d’usage. + empty: Aucun fournisseur de données disponible. + formulaires_count: + zero: Aucun formulaire + one: 1 formulaire + other: "%{count} formulaires" + formulaires: + index: + title: Formulaires + subtitle: Les formulaires proposés par %{provider_name}. + cas_usages_count: + one: 1 cas d’usage + other: "%{count} cas d’usage" + empty: Aucun formulaire associé à ce fournisseur. + show: + title: Formulaire + back: Retour aux formulaires + sections: + identification: Identification + identity: Identité + description: Description + scopes: Périmètres de données + blocks: Structure du formulaire + cas_usages: Cas d’usage + flags: Propriétés + features: Fonctionnalités + fields: + label: Champ + value: Valeur + kind: Type + link: Lien public + access_link: Lien d’accès + cgu_link: CGU + support_email: Email de support + flags: + public: Public + startable_by_applicant: Démarrable par le demandeur + unique: Unique + empty_value: Non renseigné + empty_scopes: Aucun périmètre déclaré. + empty_blocks: Aucun bloc déclaré. + empty_cas_usages: Aucun cas d’usage associé à ce formulaire. + default_badge: Par défaut + "true": Oui + "false": Non + stats: + demandes: + zero: Aucune demande + one: 1 demande + other: "%{count} demandes" + habilitations: + zero: Aucune habilitation + one: 1 habilitation + other: "%{count} habilitations" + cas_usages: + show: + back: Retour au formulaire + inherited_badge: Hérité du formulaire + sections: + identification: Identification + description: Description + introduction: Introduction + flags: Propriétés + flow: Parcours et blocs statiques + scopes_config: Configuration des périmètres + initial_data: Données initiales + service_provider: Service utilisateur + flags: + public: Public + startable_by_applicant: Démarrable par le demandeur + single_page_view: Template single-page + single_page_view_hint: "Nom optionnel du template ERB utilisé quand le formulaire est en mode single-page (steps vide). Sinon, fallback sur l’uid du cas d’usage." + single_page_view_default: "(template par défaut)" + empty_description: Aucune description. + empty_introduction: Aucune introduction. + html_tabs: + aria_label: Vue rendue ou source HTML + rendered: Rendu + html: HTML + flow: + mode: + multi_step_intro: "Blocs du formulaire dans l’ordre officiel (catalogue de la définition parente) — les blocs marqués sont statiques (pré-remplis, non éditables) :" + single_page_intro: "Pas d’étape déclarée — le formulaire est rendu en page unique." + single_page_badge: Page unique + out_of_catalog: Hors catalogue + out_of_catalog_hint: Ce bloc est référencé par le cas d’usage mais n’est pas déclaré dans la définition parente. + empty_catalog: La définition parente ne déclare aucun bloc. + tabs: + aria_label: Vue ou détail des colonnes + view: Vue + data: Colonnes + data_column: Champ + data_value: Valeur + empty_array: "[ ]" + catalog_hint: "Source de l’ordre officiel : blocs déclarés sur l’AuthorizationDefinition parente." + empty_scopes_config: Aucun périmètre déclaré sur le formulaire parent. + scopes_config_intro: "État de chaque périmètre du formulaire dans ce cas d’usage :" + scopes_config_tabs: + aria_label: Rendu des périmètres ou code de configuration + view: Rendu + code: Code + formulaire_scopes_intro: "Hérité du formulaire — périmètres déclarés sur la définition parente (catalogue complet) :" + scope_states: + displayed: Affiché + disabled: Désactivé + hidden: Masqué + preset: Préconfiguré + empty_initial_data: Aucune donnée initiale. + initial_data_tabs: + aria_label: Rendu formulaire ou données + form: Rendu + data: Données + form_intro: "Aperçu du formulaire pré-rempli pour le demandeur (champs en lecture seule) :" + no_service_provider: Pas de service utilisateur rattaché. + default_badge: Par défaut + "true": Oui + "false": Non + page_titles: + instruction: + data_providers_index: Fournisseurs de données + formulaires_index: "%{provider_name} — Formulaires" + formulaires_show: "%{formulaire_name} — %{provider_name}" + cas_usages_show: "%{cas_usage_name} — %{provider_name}" diff --git a/config/routes.rb b/config/routes.rb index b75f1eaa9a..31bf3d18f2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -150,6 +150,13 @@ get :confirm_destroy, path: 'confirmer-suppression' end end + + get '/fournisseurs-donnees', to: 'data_providers#index', as: :data_providers + scope '/fournisseurs-donnees/:provider_slug' do + resources :formulaires, only: %i[index show] do + resources :cas_usages, only: :show, path: 'cas-usage', param: :uid + end + end end get '/admin', to: 'admin#index', as: :admin diff --git a/db/migrate/20260513000001_create_form_templates.rb b/db/migrate/20260513000001_create_form_templates.rb new file mode 100644 index 0000000000..074cef8fbb --- /dev/null +++ b/db/migrate/20260513000001_create_form_templates.rb @@ -0,0 +1,26 @@ +class CreateFormTemplates < ActiveRecord::Migration[8.1] + def change + create_table :form_templates do |t| + t.string :slug, null: false + t.references :habilitation_type, null: false, foreign_key: true + t.string :service_provider_id + t.string :name + t.text :description + t.text :introduction + t.string :use_case + t.boolean :default, null: false, default: false + t.boolean :public, null: false, default: true + t.boolean :startable_by_applicant, null: false, default: true + t.boolean :single_page_view, null: false, default: false + t.jsonb :steps, null: false, default: [] + t.jsonb :static_blocks, null: false, default: [] + t.jsonb :scopes_config, null: false, default: {} + t.jsonb :initialize_with, null: false, default: {} + + t.timestamps + end + + add_index :form_templates, :slug, unique: true + add_index :form_templates, %i[habilitation_type_id default] + end +end diff --git a/db/schema.rb b/db/schema.rb index 0906346148..5736b644e9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_21_000000) do +ActiveRecord::Schema[8.1].define(version: 2026_05_13_000001) do # These are extensions that must be enabled in order to support this database enable_extension "hstore" enable_extension "pg_catalog.plpgsql" @@ -210,6 +210,29 @@ t.index ["authorization_request_id"], name: "index_denial_of_authorizations_on_authorization_request_id" end + create_table "form_templates", force: :cascade do |t| + t.datetime "created_at", null: false + t.boolean "default", default: false, null: false + t.text "description" + t.bigint "habilitation_type_id", null: false + t.jsonb "initialize_with", default: {}, null: false + t.text "introduction" + t.string "name" + t.boolean "public", default: true, null: false + t.jsonb "scopes_config", default: {}, null: false + t.string "service_provider_id" + t.boolean "single_page_view", default: false, null: false + t.string "slug", null: false + t.boolean "startable_by_applicant", default: true, null: false + t.jsonb "static_blocks", default: [], null: false + t.jsonb "steps", default: [], null: false + t.datetime "updated_at", null: false + t.string "use_case" + t.index ["habilitation_type_id", "default"], name: "index_form_templates_on_habilitation_type_id_and_default" + t.index ["habilitation_type_id"], name: "index_form_templates_on_habilitation_type_id" + t.index ["slug"], name: "index_form_templates_on_slug", unique: true + end + create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.integer "callback_priority" t.text "callback_queue_name" @@ -731,6 +754,7 @@ add_foreign_key "bulk_authorization_request_update_notification_reads", "bulk_authorization_request_updates" add_foreign_key "bulk_authorization_request_update_notification_reads", "users" add_foreign_key "denial_of_authorizations", "authorization_requests" + add_foreign_key "form_templates", "habilitation_types" add_foreign_key "habilitation_types", "data_providers" add_foreign_key "impersonation_actions", "impersonations" add_foreign_key "impersonations", "users" diff --git a/docs/technique/habilitation_type_dynamique.md b/docs/technique/habilitation_type_dynamique.md index 548f2710fd..b8cc1b9408 100644 --- a/docs/technique/habilitation_type_dynamique.md +++ b/docs/technique/habilitation_type_dynamique.md @@ -182,14 +182,46 @@ def self.backend yaml_records + db_records end +# Côté AuthorizationDefinition : 1 HabilitationType ──► 1 AuthorizationDefinition def self.db_records return [] unless HabilitationType.table_exists? HabilitationType.includes(:data_provider).map { |record| build_from_db_record(record) } rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid [] end + +# Côté AuthorizationRequestForm : 1 HabilitationType ──has_many──► N FormTemplate ──► N forms +def self.db_records + return [] unless FormTemplate.table_exists? + FormTemplate.includes(:habilitation_type).filter_map { |t| build_form_from_template(t) } +rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid + [] +end ``` +`FormTemplate` (table `form_templates`, FK `habilitation_type_id`) intercale entre +`HabilitationType` et `AuthorizationRequestForm` : un HT a 1+ FormTemplate dont +**exactement 1** marqué `default: true` (invariant garanti par les validations +`only_one_default_per_habilitation_type` + `ht_keeps_at_least_one_default` + +`ensure_not_last_default`, plus le callback +`HabilitationType#after_create :ensure_default_form_template!`). Le slug du +FormTemplate sert d'`uid` côté façade ARF. Cf. [DP-1718](https://linear.app/pole-api/issue/DP-1718). + +**Cascade éditoriale HT → FT default**. Le FormTemplate auto-créé ne porte que +`slug` + `default: true` ; les champs éditoriaux (`name`, `description`, +`introduction`, `steps`) restent **vides** côté template et sont résolus à la +volée dans `build_form_from_template` via `template..presence || ht.`. +Conséquence : éditer le HT propage automatiquement aux ARF qui en dépendent, +tant qu'aucun override explicite n'est posé sur le template. Quand l'UI admin +FormTemplate arrivera (DP-1718 PR2/PR3), renseigner un champ sur le template le +fera prendre le pas sur le HT. + +**Invalidation du cache ARF**. `FormTemplate.after_commit :reset_arf_cache` et +`HabilitationType.after_commit :reset_static_caches` (post-commit, pas +`after_save`) bumpent le compteur Redis **après** que la transaction soit +committée — autrement, un autre process pourrait reconstruire `@all` depuis +une vue pré-commit (race). + `StaticApplicationRecord` mémorise le backend dans `@all` et invalide via un compteur Redis (`Kredis.counter(redis_cache_key).increment`). Coordination indispensable en multi-process (Puma + Sidekiq) : sans bump Redis, un save @@ -202,9 +234,6 @@ le find aboutit indistinctement côté YAML ou DB. ## Limitations connues -- **`use_case` et `initialize_with` non disponibles côté DB** : - `build_form_from_habilitation_type` passe `use_case: nil` et n'expose pas - `initialize_with`. Le préremplissage par cas d'usage reste YAML-only. - **`custom_labels` (jsonb)** : présent en DB et factory, aucune lecture dans le code à ce jour. Surface réservée pour personnalisation éditoriale. - **Concerns existants non exposés au registrar** (présents dans @@ -213,16 +242,19 @@ le find aboutit indistinctement côté YAML ou DB. `gdpr_contacts`, `modalities`, `operational_acceptance`, `safety_certification`, `technical_team`, `volumetrie`. Procédure d'exposition → [ajout_block_dynamique.md](./ajout_block_dynamique.md). -- **`static_blocks` non porté côté DB** : `build_form_from_habilitation_type` - passe `static_blocks: []`. +- **CRUD admin de `FormTemplate`** : pas encore exposée en UI (DP-1718 PR 1). + La création/édition se fait en `rails c` ; le default est auto-créé à la + création d'un `HabilitationType` (callback `ensure_default_form_template!`). ## Tableau récap « où vit quoi » | Sujet | Fichier | | --- | --- | | Modèle DB | [`app/models/habilitation_type.rb`](.../../app/models/habilitation_type.rb) | +| Modèle DB templates de form | [`app/models/form_template.rb`](.../../app/models/form_template.rb) | | Migration création | [`db/migrate/20260225111739_create_habilitation_types.rb`](../db/migrate/20260225111739_create_habilitation_types.rb) | | Migration suffixe `-dyn` | [`db/migrate/20260401152848_add_dyn_suffix_to_habilitation_type_slugs.rb`](../db/migrate/20260401152848_add_dyn_suffix_to_habilitation_type_slugs.rb) | +| Migration form_templates | [`db/migrate/20260513000001_create_form_templates.rb`](../db/migrate/20260513000001_create_form_templates.rb) | | Registrar | [`app/services/dynamic_authorization_request_registrar.rb`](.../../app/services/dynamic_authorization_request_registrar.rb) | | Initializer Rails | [`config/initializers/dynamic_authorization_types.rb`](../../config/initializers/dynamic_authorization_types.rb) | | Concerns blocks | [`app/models/concerns/authorization_extensions/`](.../../app/models/concerns/authorization_extensions/) | @@ -255,4 +287,6 @@ le find aboutit indistinctement côté YAML ou DB. inactif côté wizard). Pattern `cnous_data_extraction_criteria` — voir [ajout_block_dynamique.md](./ajout_block_dynamique.md). - **Couplage `data_provider` (FK SQL)** : un type DB appartient toujours à - un `DataProvider`. Aucun couplage à un `ServiceProvider` côté DB. \ No newline at end of file + un `DataProvider`. Le couplage à un `ServiceProvider` (YAML-backed + `StaticApplicationRecord`) se fait au niveau du `FormTemplate` via la + colonne string `service_provider_id`, résolue côté façade. \ No newline at end of file diff --git a/lib/tasks/form_templates.rake b/lib/tasks/form_templates.rake new file mode 100644 index 0000000000..3daa17c444 --- /dev/null +++ b/lib/tasks/form_templates.rake @@ -0,0 +1,14 @@ +namespace :form_templates do + desc 'Backfill a default FormTemplate for HabilitationTypes that do not have one' + task backfill_defaults: :environment do + created = 0 + HabilitationType.find_each do |habilitation_type| + next if habilitation_type.form_templates.exists?(default: true) + + habilitation_type.ensure_default_form_template! + created += 1 + end + + Rails.logger.info("[form_templates:backfill_defaults] created #{created} default template(s)") + end +end diff --git a/spec/factories/form_templates.rb b/spec/factories/form_templates.rb new file mode 100644 index 0000000000..8d9b90fc9f --- /dev/null +++ b/spec/factories/form_templates.rb @@ -0,0 +1,17 @@ +FactoryBot.define do + factory :form_template do + habilitation_type + sequence(:name) { |n| "Template #{n}" } + description { 'Un template de test' } + introduction { nil } + use_case { nil } + default { false } + public { true } + startable_by_applicant { true } + single_page_view { false } + steps { [{ 'name' => 'basic_infos' }] } + static_blocks { [] } + scopes_config { {} } + initialize_with { {} } + end +end diff --git a/spec/lib/tasks/form_templates_rake_spec.rb b/spec/lib/tasks/form_templates_rake_spec.rb new file mode 100644 index 0000000000..d8ee7ffa75 --- /dev/null +++ b/spec/lib/tasks/form_templates_rake_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' +require 'rake' + +RSpec.describe 'form_templates rake tasks' do + before(:all) do + Rails.application.load_tasks if Rake::Task.tasks.none? { |t| t.name == 'form_templates:backfill_defaults' } + end + + describe 'form_templates:backfill_defaults' do + let(:task) { Rake::Task['form_templates:backfill_defaults'] } + + before { task.reenable } + + it 'creates a default FormTemplate for HTs missing one' do + ht_a = create(:habilitation_type) + ht_b = create(:habilitation_type) + ht_a.form_templates.delete_all + ht_b.form_templates.delete_all + + expect { task.invoke } + .to change { FormTemplate.where(default: true).count }.by(2) + + expect(ht_a.form_templates.where(default: true).count).to eq(1) + expect(ht_b.form_templates.where(default: true).count).to eq(1) + end + + it 'is idempotent: a second invocation does not create duplicates' do + ht = create(:habilitation_type) + ht.form_templates.delete_all + + task.invoke + task.reenable + + expect { task.invoke }.not_to(change(FormTemplate, :count)) + end + + it 'skips HTs that already have a default template' do + create(:habilitation_type) + + expect { task.invoke }.not_to(change(FormTemplate, :count)) + end + end +end diff --git a/spec/models/authorization_request_form_spec.rb b/spec/models/authorization_request_form_spec.rb index 007658bebc..a1d94e95ab 100644 --- a/spec/models/authorization_request_form_spec.rb +++ b/spec/models/authorization_request_form_spec.rb @@ -5,49 +5,103 @@ end context 'with DB records' do - before do - stub_const('AuthorizationRequest::MonAPI', Class.new(AuthorizationRequest)) - allow(described_class).to receive(:db_records).and_return([db_form]) - described_class.reset! - end + let!(:habilitation_type) { create(:habilitation_type, name: 'Mon API') } + let(:default_template) { habilitation_type.form_templates.find_by(default: true) } - after { described_class.reset! } + before { described_class.reset! } - let(:db_form) do - described_class.new( - uid: 'mon-api', - default: true, - introduction: 'Mon introduction', - authorization_request_class: AuthorizationRequest::MonAPI, - steps: [{ name: 'basic_infos' }], - static_blocks: [], - service_provider: nil, - use_case: nil, - single_page_view: nil, - scopes_config: {}, - ) - end + after { described_class.reset! } it 'includes YAML forms' do expect(described_class.all.map(&:uid)).to include('api-entreprise') end - it 'includes DB forms' do - expect(described_class.all.map(&:uid)).to include('mon-api') + it 'includes the auto-created default FormTemplate' do + expect(described_class.all.map(&:uid)).to include(default_template.slug) end it 'returns AuthorizationRequestForm instances for DB records' do - expect(described_class.all.find { |f| f.uid == 'mon-api' }).to be_a(described_class) + expect(described_class.all.find { |f| f.uid == default_template.slug }).to be_a(described_class) end - it 'has default: true for DB forms' do - form = described_class.all.find { |f| f.uid == 'mon-api' } + it 'reflects FormTemplate.default on the ARF' do + form = described_class.all.find { |f| f.uid == default_template.slug } expect(form.default).to be(true) end - it 'has the correct authorization_request_class' do - form = described_class.all.find { |f| f.uid == 'mon-api' } - expect(form.authorization_request_class).to eq(AuthorizationRequest::MonAPI) + it 'binds the correct authorization_request_class via the habilitation_type' do + form = described_class.all.find { |f| f.uid == default_template.slug } + expected_class = AuthorizationRequest.const_get(habilitation_type.uid.classify) + expect(form.authorization_request_class).to eq(expected_class) + end + + it 'exposes N templates as N forms for a single habilitation_type' do + habilitation_type.form_templates.create!(name: 'Second cas', default: false) + habilitation_type.form_templates.create!(name: 'Troisième cas', default: false) + described_class.reset! + + forms = described_class.all.select { |f| f.authorization_request_class.to_s == habilitation_type.authorization_request_type } + expect(forms.size).to eq(3) + expect(forms.count(&:default)).to eq(1) + end + + context 'when FormTemplate fields are blank (cascade from HabilitationType)' do + it 'cascades name from HT.name' do + form = described_class.all.find { |f| f.uid == default_template.slug } + + expect(default_template.name).to be_blank + expect(form.name).to eq(habilitation_type.name) + end + + it 'cascades introduction from HT.form_introduction' do + habilitation_type.update!(form_introduction: 'Bienvenue v1') + described_class.reset! + + form = described_class.all.find { |f| f.uid == default_template.slug } + expect(form.introduction).to eq('Bienvenue v1') + end + + it 'cascades steps from HT.ordered_steps when template.steps is empty' do + habilitation_type.update!(blocks: [{ 'name' => 'basic_infos' }, { 'name' => 'legal' }]) + described_class.reset! + + form = described_class.all.find { |f| f.uid == default_template.slug } + expect(form.steps).to eq([{ name: 'basic_infos' }, { name: 'legal' }]) + end + + it 'lets a non-blank template override take precedence over HT' do + habilitation_type.update!(form_introduction: 'Hérité du HT') + default_template.update!(introduction: 'Override template') + described_class.reset! + + form = described_class.all.find { |f| f.uid == default_template.slug } + expect(form.introduction).to eq('Override template') + end + end + + it 'resolves the service_provider from FormTemplate.service_provider_id' do + sp_id = ServiceProvider.all.first.id + default_template.update!(service_provider_id: sp_id) + described_class.reset! + + form = described_class.all.find { |f| f.uid == default_template.slug } + expect(form.service_provider).to eq(ServiceProvider.find(sp_id)) + end + + it 'deep-symbolizes all jsonb overrides consumed by views' do + default_template.update!( + steps: [{ 'name' => 'basic_infos', 'options' => { 'optional' => true } }], + static_blocks: [{ 'name' => 'specific_requirements' }], + scopes_config: { 'hide' => ['scope_a'] }, + initialize_with: { 'scopes' => ['scope_b'] }, + ) + described_class.reset! + + form = described_class.all.find { |f| f.uid == default_template.slug } + expect(form.steps).to eq([{ name: 'basic_infos', options: { optional: true } }]) + expect(form.static_blocks).to eq([{ name: 'specific_requirements' }]) + expect(form.scopes_config).to eq(hide: ['scope_a']) + expect(form.initialize_with[:scopes]).to include('scope_b') end end end diff --git a/spec/models/form_template_spec.rb b/spec/models/form_template_spec.rb new file mode 100644 index 0000000000..31df800445 --- /dev/null +++ b/spec/models/form_template_spec.rb @@ -0,0 +1,135 @@ +require 'rails_helper' + +RSpec.describe FormTemplate do + let(:habilitation_type) do + ht = create(:habilitation_type) + ht.form_templates.delete_all + ht + end + + describe 'creation' do + it 'persists with habilitation_type and name' do + template = described_class.create!(habilitation_type:, name: 'Mon Template') + + expect(template).to be_persisted + expect(template.slug).to be_present + end + end + + describe 'slug' do + it 'is auto-generated from name via friendly_id' do + template = described_class.create!(habilitation_type:, name: 'Mon Template Super') + + expect(template.slug).to eq('mon-template-super') + end + + it 'is unique' do + described_class.create!(habilitation_type:, name: 'Identique') + duplicate = described_class.new(habilitation_type:, name: 'Autre nom', slug: 'identique') + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:slug]).to be_present + end + + it 'is refused if it collides with a YAML form uid' do + yaml_uid = AuthorizationRequestFormConfigurations.instance.all.keys.first.to_s + template = described_class.new(habilitation_type:, name: 'Conflit', slug: yaml_uid) + + expect(template).not_to be_valid + expect(template.errors[:slug]).to be_present + end + end + + describe 'default invariant' do + it 'cannot be flipped from true to false when it is the only default' do + default_template = described_class.create!(habilitation_type:, name: 'Default', default: true) + described_class.create!(habilitation_type:, name: 'Other', default: false) + + default_template.default = false + + expect(default_template.save).to be(false) + expect(default_template.errors[:default]).to be_present + end + + it 'refuses a second default for the same habilitation_type' do + described_class.create!(habilitation_type:, name: 'A', default: true) + duplicate_default = described_class.new(habilitation_type:, name: 'B', default: true) + + expect(duplicate_default).not_to be_valid + expect(duplicate_default.errors[:default]).to be_present + end + end + + describe '#destroy' do + it 'is forbidden when destroying the last default of a habilitation_type' do + default_template = described_class.create!(habilitation_type:, name: 'Default', default: true) + described_class.create!(habilitation_type:, name: 'Other', default: false) + + expect(default_template.destroy).to be(false) + expect(default_template.errors[:base]).to be_present + expect { default_template.reload }.not_to raise_error + end + + it 'is allowed for a non-default template' do + described_class.create!(habilitation_type:, name: 'Default', default: true) + other = described_class.create!(habilitation_type:, name: 'Other', default: false) + + expect { other.destroy }.to change(described_class, :count).by(-1) + end + end + + describe '#service_provider' do + it 'returns nil when no service_provider_id is set' do + template = described_class.create!(habilitation_type:, name: 'No SP') + + expect(template.service_provider).to be_nil + end + + it 'resolves the ServiceProvider from the YAML backend when set' do + sp_id = ServiceProvider.all.first.id + template = described_class.create!(habilitation_type:, name: 'With SP', service_provider_id: sp_id) + + expect(template.service_provider).to eq(ServiceProvider.find(sp_id)) + end + + it 'rejects an invalid service_provider_id at save time' do + template = described_class.new(habilitation_type:, name: 'Bad SP', service_provider_id: 'inexistant') + + expect(template).not_to be_valid + expect(template.errors[:service_provider_id]).to be_present + end + end + + describe 'paper_trail' do + it 'tracks a version on update' do + template = described_class.create!(habilitation_type:, name: 'Versionné') + + expect { template.update!(description: 'nouvelle description') } + .to change { template.versions.count }.by(1) + end + end + + describe 'AuthorizationRequestForm cache invalidation' do + before { AuthorizationRequestForm.reset! } + + after { AuthorizationRequestForm.reset! } + + it 'exposes a freshly created FormTemplate to the façade without manual reset' do + AuthorizationRequestForm.all + + template = described_class.create!(habilitation_type:, name: 'Frais') + + expect(AuthorizationRequestForm.all.map(&:uid)).to include(template.slug) + end + + it 'removes a destroyed FormTemplate from the façade without manual reset' do + described_class.create!(habilitation_type:, name: 'Default conservé', default: true) + removable = described_class.create!(habilitation_type:, name: 'À jeter', default: false) + AuthorizationRequestForm.all + + removable.destroy! + + expect(AuthorizationRequestForm.all.map(&:uid)).not_to include(removable.slug) + end + end +end diff --git a/spec/models/habilitation_type_spec.rb b/spec/models/habilitation_type_spec.rb index 5e2282bb0f..a3dc7bb443 100644 --- a/spec/models/habilitation_type_spec.rb +++ b/spec/models/habilitation_type_spec.rb @@ -192,7 +192,8 @@ it 'creates a version on destroy' do habilitation_type.save! - expect { habilitation_type.destroy! }.to change(PaperTrail::Version, :count).by(1) + expect { habilitation_type.destroy! } + .to change { PaperTrail::Version.where(item_type: 'HabilitationType').count }.by(1) end it 'tracks attribute changes as a Hash in object_changes' do @@ -236,6 +237,28 @@ user = build(:user) expect(user).to respond_to(:"instruction_submit_notifications_for_#{habilitation_type.uid}") end + + it 'auto-creates a default FormTemplate carrying only slug + default (editorial fields cascade from HT)' do + habilitation_type.blocks = [{ 'name' => 'basic_infos' }, { 'name' => 'legal' }] + habilitation_type.form_introduction = 'Introduction du formulaire' + habilitation_type.save! + + default_template = habilitation_type.form_templates.find_by(default: true) + expect(default_template).to be_present + expect(default_template.slug).to eq("#{habilitation_type.slug}-default") + expect(default_template.name).to be_blank + expect(default_template.introduction).to be_blank + expect(default_template.steps).to eq([]) + end + end + + describe '#ensure_default_form_template!' do + it 'is idempotent: does not create a second default' do + habilitation_type.save! + + expect { habilitation_type.ensure_default_form_template! } + .not_to change { habilitation_type.form_templates.where(default: true).count } + end end describe 'validation on destroy' do @@ -272,7 +295,7 @@ end it 'resets AuthorizationRequestForm cache' do - expect(AuthorizationRequestForm).to receive(:reset!) + expect(AuthorizationRequestForm).to receive(:reset!).at_least(:once) habilitation_type.destroy! end diff --git a/spec/policies/instruction/authorization_definition_policy_spec.rb b/spec/policies/instruction/authorization_definition_policy_spec.rb new file mode 100644 index 0000000000..520a72b349 --- /dev/null +++ b/spec/policies/instruction/authorization_definition_policy_spec.rb @@ -0,0 +1,39 @@ +RSpec.describe Instruction::AuthorizationDefinitionPolicy do + subject(:policy) { described_class.new(UserContext.new(user), definition) } + + let(:definition) { AuthorizationDefinition.find('api_particulier') } + + describe '#show?' do + subject { policy.show? } + + context 'when user is admin' do + let(:user) { create(:user, :admin) } + + it { is_expected.to be true } + end + + context 'when user has FD-wide role on the provider' do + let(:user) { create(:user, :fd_reporter, data_provider_slugs: %w[dinum]) } + + it { is_expected.to be true } + end + + context 'when user has a definition-specific role' do + let(:user) { create(:user, :reporter, authorization_request_types: %w[api_particulier]) } + + it { is_expected.to be true } + end + + context 'when user has a role on another definition' do + let(:user) { create(:user, :reporter, authorization_request_types: %w[api_entreprise]) } + + it { is_expected.to be false } + end + + context 'when user has no role' do + let(:user) { create(:user) } + + it { is_expected.to be false } + end + end +end diff --git a/spec/policies/instruction/authorization_request_form_policy_spec.rb b/spec/policies/instruction/authorization_request_form_policy_spec.rb new file mode 100644 index 0000000000..ea2efaaac0 --- /dev/null +++ b/spec/policies/instruction/authorization_request_form_policy_spec.rb @@ -0,0 +1,27 @@ +RSpec.describe Instruction::AuthorizationRequestFormPolicy do + subject(:policy) { described_class.new(UserContext.new(user), form) } + + let(:form) { AuthorizationRequestForm.where(authorization_request_class: AuthorizationRequest::APIParticulier).first } + + describe '#show?' do + subject { policy.show? } + + context 'when user is admin' do + let(:user) { create(:user, :admin) } + + it { is_expected.to be true } + end + + context 'when user has FD-wide role on the provider' do + let(:user) { create(:user, :fd_reporter, data_provider_slugs: %w[dinum]) } + + it { is_expected.to be true } + end + + context 'when user has a role on another definition' do + let(:user) { create(:user, :reporter, authorization_request_types: %w[api_entreprise]) } + + it { is_expected.to be false } + end + end +end diff --git a/spec/policies/instruction/data_provider_policy_spec.rb b/spec/policies/instruction/data_provider_policy_spec.rb new file mode 100644 index 0000000000..42c5e50326 --- /dev/null +++ b/spec/policies/instruction/data_provider_policy_spec.rb @@ -0,0 +1,60 @@ +RSpec.describe Instruction::DataProviderPolicy do + subject(:policy) { described_class.new(UserContext.new(user), data_provider) } + + let!(:data_provider) { create(:data_provider, :dinum) } + let!(:other_provider) { create(:data_provider, :dgfip) } + + describe '#show?' do + subject { policy.show? } + + context 'when user is admin' do + let(:user) { create(:user, :admin) } + + it { is_expected.to be true } + end + + context 'when user has a reporter role on the FD' do + let(:user) { create(:user, :fd_reporter, data_provider_slugs: %w[dinum]) } + + it { is_expected.to be true } + end + + context 'when user has a reporter role on another FD' do + let(:user) { create(:user, :fd_reporter, data_provider_slugs: %w[cnam]) } + + it { is_expected.to be false } + end + + context 'when user has no role' do + let(:user) { create(:user) } + + it { is_expected.to be false } + end + end + + describe 'Scope#resolve' do + subject { described_class::Scope.new(UserContext.new(user), DataProvider).resolve } + + context 'when user is admin' do + let(:user) { create(:user, :admin) } + + it 'returns all providers' do + expect(subject.count).to eq(DataProvider.count) + end + end + + context 'when user has a single FD role' do + let(:user) { create(:user, :fd_instructor, data_provider_slugs: %w[dinum]) } + + it 'returns only that provider' do + expect(subject.pluck(:slug)).to eq(%w[dinum]) + end + end + + context 'when user has no role' do + let(:user) { create(:user) } + + it { is_expected.to be_empty } + end + end +end diff --git a/spec/requests/instruction/cas_usages_spec.rb b/spec/requests/instruction/cas_usages_spec.rb new file mode 100644 index 0000000000..c8f2d624e5 --- /dev/null +++ b/spec/requests/instruction/cas_usages_spec.rb @@ -0,0 +1,74 @@ +RSpec.describe 'Instruction::CasUsages' do + let(:data_provider) { create(:data_provider, :dgfip) } + let(:formulaire_id) { 'api_impot_particulier' } + let(:cas_usage_uid) { AuthorizationDefinition.find(formulaire_id).available_forms.first.uid } + + describe 'GET /instruction/fournisseurs-donnees/:slug/formulaires/:formulaire_id/cas-usage/:uid' do + subject(:get_show) do + get instruction_formulaire_cas_usage_path( + provider_slug: data_provider.slug, + formulaire_id: formulaire_id, + uid: cas_usage_uid + ) + response + end + + context 'when user is admin' do + before { sign_in(create(:user, :admin)) } + + it { is_expected.to have_http_status(:ok) } + + it 'returns 404 when the formulaire belongs to another provider' do + other_provider = create(:data_provider, :dinum) + get instruction_formulaire_cas_usage_path( + provider_slug: other_provider.slug, + formulaire_id: formulaire_id, + uid: cas_usage_uid + ) + expect(response).to have_http_status(:not_found) + end + + it 'returns 404 when the cas d’usage uid is unknown' do + get instruction_formulaire_cas_usage_path( + provider_slug: data_provider.slug, + formulaire_id: formulaire_id, + uid: 'does_not_exist' + ) + expect(response).to have_http_status(:not_found) + end + + it 'returns 404 when the data provider slug is unknown' do + get instruction_formulaire_cas_usage_path( + provider_slug: 'unknown', + formulaire_id: formulaire_id, + uid: cas_usage_uid + ) + expect(response).to have_http_status(:not_found) + end + end + + context 'when user is FD reporter scoped to the provider' do + before { sign_in(create(:user, :fd_reporter, data_provider_slugs: %w[dgfip])) } + + it { is_expected.to have_http_status(:ok) } + end + + context 'when user is reporter scoped to another FD' do + before { sign_in(create(:user, :fd_reporter, data_provider_slugs: %w[dinum])) } + + it 'redirects (unauthorized)' do + get_show + expect(response).to have_http_status(:redirect) + end + end + + context 'when user has no reporter coverage' do + before { sign_in(create(:user)) } + + it 'redirects to dashboard (InstructionController gate)' do + get_show + expect(response).to redirect_to(dashboard_path) + end + end + end +end diff --git a/spec/requests/instruction/data_providers_spec.rb b/spec/requests/instruction/data_providers_spec.rb new file mode 100644 index 0000000000..802c962a81 --- /dev/null +++ b/spec/requests/instruction/data_providers_spec.rb @@ -0,0 +1,51 @@ +RSpec.describe 'Instruction::DataProviders' do + let!(:dgfip) { create(:data_provider, :dgfip) } + let!(:dinum) { create(:data_provider, :dinum) } + + describe 'GET /instruction/fournisseurs-donnees' do + subject(:get_index) do + get instruction_data_providers_path + response + end + + context 'when user is admin' do + before { sign_in(create(:user, :admin)) } + + it { is_expected.to have_http_status(:ok) } + + it 'lists every provider' do + get_index + expect(response.body).to include(dgfip.name) + expect(response.body).to include(dinum.name) + end + end + + context 'when user is FD reporter scoped to one provider' do + before { sign_in(create(:user, :fd_reporter, data_provider_slugs: %w[dgfip])) } + + it { is_expected.to have_http_status(:ok) } + + it 'lists only the accessible provider' do + get_index + expect(response.body).to include(dgfip.name) + expect(response.body).not_to include(dinum.name) + end + end + + context 'when user has no reporter coverage' do + before { sign_in(create(:user)) } + + it 'redirects to dashboard (InstructionController gate)' do + get_index + expect(response).to redirect_to(dashboard_path) + end + end + + context 'when unauthenticated' do + it 'redirects' do + get_index + expect(response).to have_http_status(:redirect) + end + end + end +end diff --git a/spec/requests/instruction/formulaires_spec.rb b/spec/requests/instruction/formulaires_spec.rb new file mode 100644 index 0000000000..8bb82db024 --- /dev/null +++ b/spec/requests/instruction/formulaires_spec.rb @@ -0,0 +1,107 @@ +RSpec.describe 'Instruction::Formulaires' do + let(:data_provider) { create(:data_provider, :dgfip) } + let(:formulaire_id) { 'api_impot_particulier' } + + describe 'GET /instruction/fournisseurs-donnees/:slug/formulaires' do + subject(:get_index) do + get instruction_formulaires_path(provider_slug: data_provider.slug) + response + end + + context 'when user is admin' do + before { sign_in(create(:user, :admin)) } + + it { is_expected.to have_http_status(:ok) } + + it 'renders the data provider name' do + get_index + expect(response.body).to include(data_provider.name) + end + + context 'when the provider slug does not exist' do + it 'returns 404' do + get instruction_formulaires_path(provider_slug: 'unknown') + expect(response).to have_http_status(:not_found) + end + end + end + + context 'when user is FD reporter scoped to the provider' do + before { sign_in(create(:user, :fd_reporter, data_provider_slugs: %w[dgfip])) } + + it { is_expected.to have_http_status(:ok) } + end + + context 'when user is reporter scoped to another FD' do + before { sign_in(create(:user, :fd_reporter, data_provider_slugs: %w[dinum])) } + + it 'redirects (unauthorized)' do + get_index + expect(response).to have_http_status(:redirect) + end + end + + context 'when user has no reporter coverage' do + before { sign_in(create(:user)) } + + it 'redirects to dashboard (InstructionController gate)' do + get_index + expect(response).to redirect_to(dashboard_path) + end + end + + context 'when unauthenticated' do + it 'redirects' do + get_index + expect(response).to have_http_status(:redirect) + end + end + end + + describe 'GET /instruction/fournisseurs-donnees/:slug/formulaires/:id' do + context 'when user is admin' do + before { sign_in(create(:user, :admin)) } + + it 'returns 200 on happy path' do + get instruction_formulaire_path(provider_slug: data_provider.slug, id: formulaire_id) + expect(response).to have_http_status(:ok) + expect(response.body).to include('API Impôt Particulier') + end + + it 'returns 404 when the formulaire belongs to another provider' do + other_provider = create(:data_provider, :dinum) + get instruction_formulaire_path(provider_slug: other_provider.slug, id: formulaire_id) + expect(response).to have_http_status(:not_found) + end + + it 'returns 404 when the formulaire id is unknown' do + get instruction_formulaire_path(provider_slug: data_provider.slug, id: 'does_not_exist') + expect(response).to have_http_status(:not_found) + end + + it 'renders demandes + habilitations stats on cas d’usage cards' do + get instruction_formulaire_path(provider_slug: data_provider.slug, id: formulaire_id) + expect(response.body).to match(/demande/i) + expect(response.body).to match(/habilitation/i) + end + end + + context 'when user is FD reporter scoped to the provider' do + before { sign_in(create(:user, :fd_reporter, data_provider_slugs: %w[dgfip])) } + + it 'returns 200' do + get instruction_formulaire_path(provider_slug: data_provider.slug, id: formulaire_id) + expect(response).to have_http_status(:ok) + end + end + + context 'when user is reporter scoped to another FD' do + before { sign_in(create(:user, :fd_reporter, data_provider_slugs: %w[dinum])) } + + it 'redirects (unauthorized)' do + get instruction_formulaire_path(provider_slug: data_provider.slug, id: formulaire_id) + expect(response).to have_http_status(:redirect) + end + end + end +end