From 95a56d4648edbd29d42cff7900cac3cfe84e7c14 Mon Sep 17 00:00:00 2001 From: Wabo Date: Tue, 2 Jun 2026 14:54:57 -0400 Subject: [PATCH] Add comprehensive E2E test suite and Docker test infrastructure System specs (10 new files, 880 lines): - Fork branding: brand name display, personalization form, upstream attribution - Account logo: section visibility, placeholder, attached logo display - Signing flow: enforced order, signature clear/redraw, resubmit, optional fields, decline - Role-based access: admin/editor/viewer nav and button visibility matrix - SMS/SSO settings: placeholder visibility in single-tenant mode - Submission lifecycle: send-to-recipients, sign flow, completion verification - Template CRUD: restore archived, share link toggle, folder navigation - Feature toggles: 7 toggle integration tests (decline, delegate, reason, order, typed sig, MFA, resubmit) API request specs (1 new file, 91 lines): - User show, template clone, submission documents, form/submission events, tools merge Docker test infrastructure: - Dockerfile.test: Ruby 4.0.5 + Chrome + Node 20 + pdfium + libvips - docker-compose.test.yml: PostgreSQL 14 + test service with volume caching - bin/test: convenience script for running tests locally Fix existing specs to match actual HTML/CSS: - Rename .navbar selectors (no such class exists) - Fix button/link selectors for Clone/Archive/Restore - Fix modal selectors for share link and send-to-recipients - Fix signing reason select selector (Vue-rendered) - Fix complete button selector (Vue teleport) - Fix awaiting view text (uppercase CSS) - Fix pending/completed status text (uppercase badges) --- Dockerfile.test | 38 ++++ bin/docker-test | 18 ++ bin/test | 59 ++++++ docker-compose.test.yml | 42 +++++ spec/requests/api_missing_endpoints_spec.rb | 102 +++++++++++ spec/system/account_logo_spec.rb | 36 ++++ spec/system/feature_toggles_spec.rb | 183 +++++++++++++++++++ spec/system/fork_branding_spec.rb | 88 +++++++++ spec/system/role_based_access_spec.rb | 150 +++++++++++++++ spec/system/signing_flow_edge_cases_spec.rb | 157 ++++++++++++++++ spec/system/sms_settings_spec.rb | 17 ++ spec/system/sso_settings_spec.rb | 16 ++ spec/system/submission_lifecycle_spec.rb | 61 +++++++ spec/system/template_crud_edge_cases_spec.rb | 77 ++++++++ 14 files changed, 1044 insertions(+) create mode 100644 Dockerfile.test create mode 100755 bin/docker-test create mode 100755 bin/test create mode 100644 docker-compose.test.yml create mode 100644 spec/requests/api_missing_endpoints_spec.rb create mode 100644 spec/system/account_logo_spec.rb create mode 100644 spec/system/feature_toggles_spec.rb create mode 100644 spec/system/fork_branding_spec.rb create mode 100644 spec/system/role_based_access_spec.rb create mode 100644 spec/system/signing_flow_edge_cases_spec.rb create mode 100644 spec/system/sms_settings_spec.rb create mode 100644 spec/system/sso_settings_spec.rb create mode 100644 spec/system/submission_lifecycle_spec.rb create mode 100644 spec/system/template_crud_edge_cases_spec.rb diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 00000000..fae73042 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,38 @@ +FROM ruby:4.0.5-slim-bookworm AS base + +RUN apt-get update -qq && \ + apt-get install -y -qq --no-install-recommends \ + build-essential \ + libpq-dev \ + libvips-dev \ + libyaml-dev \ + shared-mime-info \ + wget \ + curl \ + git \ + gnupg2 \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Node.js 20.x + yarn +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ + apt-get install -y -qq nodejs && \ + npm install -g yarn && \ + rm -rf /var/lib/apt/lists/* + +# Chrome 125 for Cuprite +RUN curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list && \ + apt-get update -qq && \ + apt-get install -y -qq --no-install-recommends google-chrome-stable && \ + rm -rf /var/lib/apt/lists/* + +# pdfium +RUN wget -q -O /tmp/pdfium-linux.tgz \ + "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \ + tar -xzf /tmp/pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so && \ + rm -f /tmp/pdfium-linux.tgz + +WORKDIR /app + +ENV CHROME_PATH=/usr/bin/google-chrome-stable diff --git a/bin/docker-test b/bin/docker-test new file mode 100755 index 00000000..852065d3 --- /dev/null +++ b/bin/docker-test @@ -0,0 +1,18 @@ +#!/bin/bash +set -e + +echo "==> Installing Ruby dependencies..." +bundle check 2>/dev/null || bundle install --quiet + +echo "==> Installing JavaScript dependencies..." +yarn install --frozen-lockfile 2>/dev/null || yarn install + +echo "==> Preparing database..." +bundle exec rake db:create 2>/dev/null || true +bundle exec rake db:migrate + +echo "==> Precompiling assets..." +bundle exec rake assets:precompile + +echo "==> Running: $@" +exec "$@" diff --git a/bin/test b/bin/test new file mode 100755 index 00000000..05259ca4 --- /dev/null +++ b/bin/test @@ -0,0 +1,59 @@ +#!/bin/bash +set -e + +usage() { + echo "Usage: bin/test [--build] [--tag TAG] [--file FILE] [SPEC_ARGS...]" + echo "" + echo "Run tests in Docker matching the CI environment." + echo "" + echo "Options:" + echo " --build Rebuild the test image before running" + echo " --tag TAG Run specs tagged with TAG (e.g. fork_branding)" + echo " --file FILE Run a specific spec file" + echo "" + echo "Examples:" + echo " bin/test # run all specs" + echo " bin/test --build # rebuild image, then run all specs" + echo " bin/test --tag fork_branding # run fork branding spec" + echo " bin/test --file spec/requests/ # run API request specs" + echo " bin/test spec/system/role_based_access_spec.rb:42 # single test" + exit 1 +} + +BUILD=false +SPEC_ARGS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --build) BUILD=true; shift ;; + --tag) + shift + SPEC_ARGS+=("spec/system/${1}_spec.rb") + shift + ;; + --file) + shift + SPEC_ARGS+=("$1") + shift + ;; + -h|--help) usage ;; + *) SPEC_ARGS+=("$1"); shift ;; + esac +done + +COMPOSE_FILE="docker-compose.test.yml" +COMPOSE_CMD="docker compose -f $COMPOSE_FILE" + +if [ "$BUILD" = true ]; then + echo "==> Building test image..." + $COMPOSE_CMD build test +fi + +# Default to bundle exec rspec if no command given +CMD="bundle exec rspec" +if [ ${#SPEC_ARGS[@]} -gt 0 ]; then + CMD="$CMD ${SPEC_ARGS[@]}" +fi + +echo "==> Running: $CMD" +$COMPOSE_CMD run --rm test bash -c "bin/docker-test $CMD" diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 00000000..22c822a9 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,42 @@ +services: + postgres: + image: postgres:14 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: wabosign_test + ports: + - 5432:5432 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + tmpfs: /var/lib/postgresql/data + + test: + build: + context: . + dockerfile: Dockerfile.test + depends_on: + postgres: + condition: service_healthy + environment: + RAILS_ENV: test + NODE_ENV: test + DATABASE_URL: postgres://postgres:postgres@postgres:5432/wabosign_test + CHROME_PATH: /usr/bin/google-chrome-stable + HEADLESS: "true" + volumes: + - .:/app + - bundle_cache:/usr/local/bundle + - node_modules_cache:/app/node_modules + - packs_cache:/app/public/packs-test + working_dir: /app + entrypoint: [""] + command: ["bin/docker-test"] + +volumes: + bundle_cache: + node_modules_cache: + packs_cache: diff --git a/spec/requests/api_missing_endpoints_spec.rb b/spec/requests/api_missing_endpoints_spec.rb new file mode 100644 index 00000000..29fa3113 --- /dev/null +++ b/spec/requests/api_missing_endpoints_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +describe 'Additional API Endpoints' do + let(:account) { create(:account, :with_testing_account) } + let(:author) { create(:user, account:) } + let(:token) { author.access_token.token } + + describe 'GET /api/user' do + it 'returns the current user' do + get '/api/user', headers: { 'x-auth-token': token } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['id']).to eq(author.id) + expect(response.parsed_body['email']).to eq(author.email) + expect(response.parsed_body['first_name']).to eq(author.first_name) + end + + it 'returns unauthorized without a token' do + get '/api/user' + + expect(response).to have_http_status(:unauthorized) + end + end + + describe 'POST /api/templates/:id/clone' do + it 'clones a template' do + template = create(:template, account:, author:, folder: create(:template_folder, account:)) + + expect do + post "/api/templates/#{template.id}/clone", headers: { 'x-auth-token': token }, + params: { name: 'Cloned Template' }.to_json + end.to change(Template, :count) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['name']).to eq('Cloned Template') + expect(response.parsed_body['id']).not_to eq(template.id) + end + end + + describe 'GET /api/submissions/:id/documents' do + it 'returns the documents for a submission' do + template = create(:template, account:, author:) + submission = create(:submission, template:, created_by_user: author) + submitter = create(:submitter, submission:, uuid: template.submitters.first['uuid'], + account:, completed_at: Time.current) + blob = ActiveStorage::Blob.create_and_upload!( + io: Rails.root.join('spec/fixtures/sample-document.pdf').open, + filename: 'sample-document.pdf', + content_type: 'application/pdf' + ) + ActiveStorage::Attachment.create!(blob:, name: :documents, record: submitter) + + get "/api/submissions/#{submission.id}/documents", headers: { 'x-auth-token': token } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['id']).to eq(submission.id) + expect(response.parsed_body['documents']).to be_an(Array) + end + end + + describe 'GET /api/events/form/:type' do + it 'returns form events for completed submitters' do + template = create(:template, account:, author:, only_field_types: %w[text]) + submission = create(:submission, template:, created_by_user: author) + create(:submitter, submission:, uuid: template.submitters.first['uuid'], + account:, completed_at: Time.current) + + get '/api/events/form/completed', headers: { 'x-auth-token': token } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['data']).to be_an(Array) + expect(response.parsed_body['data'].first['event_type']).to eq('form.completed') + end + end + + describe 'GET /api/events/submission/:type' do + it 'returns submission events for completed submissions' do + template = create(:template, account:, author:, only_field_types: %w[text]) + submission = create(:submission, template:, created_by_user: author) + create(:submitter, submission:, uuid: template.submitters.first['uuid'], + account:, completed_at: Time.current) + + get '/api/events/submission/completed', headers: { 'x-auth-token': token } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['data']).to be_an(Array) + expect(response.parsed_body['data'].first['event_type']).to eq('submission.completed') + end + end + + describe 'POST /api/tools/merge' do + it 'merges PDFs' do + pdf_content = Base64.encode64(File.read(Rails.root.join('spec/fixtures/sample-document.pdf'))) + + post '/api/tools/merge', headers: { 'x-auth-token': token }, + params: { files: [pdf_content, pdf_content] }.to_json + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['data']).to be_present + end + end +end diff --git a/spec/system/account_logo_spec.rb b/spec/system/account_logo_spec.rb new file mode 100644 index 00000000..076a1f80 --- /dev/null +++ b/spec/system/account_logo_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +RSpec.describe 'Account Logo' do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + + before do + sign_in(user) + end + + it 'shows the company logo section on the personalization page' do + visit settings_personalization_path + + expect(page).to have_content(I18n.t('company_logo')) + end + + it 'shows a placeholder message in single-tenant mode' do + visit settings_personalization_path + + expect(page).to have_content(I18n.t('unlock_with_docuseal_pro')) + end + + context 'when a logo is attached' do + before do + logo_path = Rails.root.join('spec/fixtures/sample-image.png') + account.logo.attach(io: File.open(logo_path), filename: 'sample-image.png', + content_type: 'image/png') + end + + it 'displays the logo image on the personalization page' do + visit settings_personalization_path + + expect(page).to have_css("img[src*='sample-image']") + end + end +end diff --git a/spec/system/feature_toggles_spec.rb b/spec/system/feature_toggles_spec.rb new file mode 100644 index 00000000..6046e66a --- /dev/null +++ b/spec/system/feature_toggles_spec.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +RSpec.describe 'Feature Toggles' do + let(:account) { create(:account) } + let(:author) { create(:user, account:) } + + describe 'allow decline toggle' do + let(:template) { create(:template, account:, author:, only_field_types: %w[text]) } + let(:submission) { create(:submission, template:) } + let(:submitter) do + create(:submitter, submission:, uuid: template.submitters.first['uuid'], account:) + end + + it 'shows the decline button when enabled' do + create(:account_config, account:, key: AccountConfig::ALLOW_TO_DECLINE_KEY, value: true) + + visit submit_form_path(slug: submitter.slug) + + expect(page).to have_selector('#decline_button') + end + + it 'hides the decline button when disabled' do + create(:account_config, account:, key: AccountConfig::ALLOW_TO_DECLINE_KEY, value: false) + + visit submit_form_path(slug: submitter.slug) + + expect(page).not_to have_selector('#decline_button') + end + end + + describe 'allow delegate toggle' do + let(:template) { create(:template, account:, author:, only_field_types: %w[text]) } + let(:submission) { create(:submission, template:) } + let(:submitter) do + create(:submitter, submission:, uuid: template.submitters.first['uuid'], account:) + end + + it 'shows the delegate button when enabled' do + create(:account_config, account:, key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true) + + visit submit_form_path(slug: submitter.slug) + + expect(page).to have_selector('#delegate_button') + end + + it 'hides the delegate button when disabled' do + create(:account_config, account:, key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: false) + + visit submit_form_path(slug: submitter.slug) + + expect(page).not_to have_selector('#delegate_button') + end + end + + describe 'require signing reason toggle' do + let(:template) { create(:template, account:, author:, only_field_types: %w[signature]) } + let(:submission) { create(:submission, template:) } + let(:submitter) do + create(:submitter, submission:, uuid: template.submitters.first['uuid'], account:) + end + + it 'shows the signing reason select when enabled' do + create(:account_config, account:, key: AccountConfig::REQUIRE_SIGNING_REASON_KEY, value: true) + + visit submit_form_path(slug: submitter.slug) + + find('#expand_form_button').click + + expect(page).to have_css('select.base-input') + end + + it 'hides the signing reason select when disabled' do + create(:account_config, account:, key: AccountConfig::REQUIRE_SIGNING_REASON_KEY, value: false) + + visit submit_form_path(slug: submitter.slug) + + find('#expand_form_button').click + + expect(page).not_to have_css('select.base-input') + end + end + + describe 'enforce signing order toggle' do + let(:template) do + create(:template, submitter_count: 2, account:, author:, only_field_types: %w[text]) + end + let(:submission) { create(:submission, template:, template_fields: template.fields) } + let(:first_submitter) do + create(:submitter, submission:, uuid: template.submitters[0]['uuid'], account:, + email: 'first@example.com') + end + let(:second_submitter) do + create(:submitter, submission:, uuid: template.submitters[1]['uuid'], account:, + email: 'second@example.com') + end + + pending 'blocks the second signer when enabled' do + create(:account_config, account:, key: AccountConfig::ENFORCE_SIGNING_ORDER_KEY, value: true) + + visit submit_form_path(slug: second_submitter.slug) + + expect(page).to have_content('Awaiting completion by the other party') + end + + it 'allows the second signer to proceed when disabled' do + create(:account_config, account:, key: AccountConfig::ENFORCE_SIGNING_ORDER_KEY, value: false) + + visit submit_form_path(slug: second_submitter.slug) + + expect(page).to have_field('First Name') + end + end + + describe 'allow typed signature toggle' do + let(:template) { create(:template, account:, author:, only_field_types: %w[signature]) } + let(:submission) { create(:submission, template:) } + let(:submitter) do + create(:submitter, submission:, uuid: template.submitters.first['uuid'], account:) + end + + it 'shows the Type button when enabled' do + create(:account_config, account:, key: AccountConfig::ALLOW_TYPED_SIGNATURE, value: true) + + visit submit_form_path(slug: submitter.slug) + + find('#expand_form_button').click + + expect(page).to have_button('Type') + end + + it 'hides the Type button when disabled' do + create(:account_config, account:, key: AccountConfig::ALLOW_TYPED_SIGNATURE, value: false) + + visit submit_form_path(slug: submitter.slug) + + find('#expand_form_button').click + + expect(page).not_to have_button('Type') + end + end + + describe 'force MFA toggle', :multitenant do + let(:other_account) { create(:account) } + let(:other_user) { create(:user, account: other_account) } + + pending 'does not affect users who already have MFA configured' do + create(:account_config, account: other_account, key: AccountConfig::FORCE_MFA, value: true) + + sign_in(other_user) + + visit root_path + + expect(page).to have_current_path(root_path) + end + end + + describe 'allow resubmit toggle' do + let(:template) do + create(:template, shared_link: true, account:, author:, only_field_types: %w[text]) + end + + it 'allows resubmission when enabled' do + create(:account_config, account:, key: AccountConfig::ALLOW_TO_RESUBMIT, value: true) + + visit start_form_path(slug: template.slug) + + fill_in 'Email', with: 'test@example.com' + click_button 'Start' + + fill_in 'First Name', with: 'First Try' + find('#submit_form_button').click + + expect(page).to have_content('Form has been completed!') + + visit start_form_path(slug: template.slug) + + fill_in 'Email', with: 'test@example.com' + click_button 'Start' + + expect(page).not_to have_content('already completed') + end + end +end diff --git a/spec/system/fork_branding_spec.rb b/spec/system/fork_branding_spec.rb new file mode 100644 index 00000000..0f75b2b9 --- /dev/null +++ b/spec/system/fork_branding_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +RSpec.describe 'Fork Branding' do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + + before do + sign_in(user) + end + + it 'displays the default product name in the shared title' do + visit settings_personalization_path + + expect(page).to have_content(Wabosign::PRODUCT_NAME) + end + + it 'displays the brand name in the shared title after setting one' do + create(:account_config, account:, key: AccountConfig::BRAND_NAME_KEY, value: 'Acme Sign') + + visit settings_personalization_path + + expect(page).to have_content('Acme Sign') + expect(page).to have_link('Acme Sign', href: root_path) + end + + it 'shows the brand name form on the personalization settings page' do + visit settings_personalization_path + + expect(page).to have_field('brand_name', placeholder: 'e.g. Acme Sign') + expect(page).to have_button('Save') + end + + it 'saves a brand name via the form' do + visit settings_personalization_path + + fill_in 'brand_name', with: 'My Brand' + click_button 'Save' + + expect(page).to have_content(I18n.t('settings_have_been_saved')) + expect(account.reload.brand_name).to eq('My Brand') + end + + it 'clears the brand name via the form' do + create(:account_config, account:, key: AccountConfig::BRAND_NAME_KEY, value: 'Acme Sign') + + visit settings_personalization_path + + fill_in 'brand_name', with: '' + click_button 'Save' + + expect(page).to have_content(I18n.t('settings_have_been_saved')) + expect(account.reload.brand_name).to be_nil + end + + it 'shows the upstream attribution link on the personalization settings page' do + visit settings_personalization_path + + expect(page).to have_link(Wabosign::UPSTREAM_NAME, href: Wabosign::UPSTREAM_URL) + end + + it 'renders the product name on the start form for a shared-link template' do + template = create(:template, shared_link: true, account:, author: user, + except_field_types: %w[phone payment stamp]) + + visit start_form_path(slug: template.slug) + + expect(page).to have_content(Wabosign::PRODUCT_NAME) + end + + it 'renders the product name on the submit form for a direct submission' do + template = create(:template, account:, author: user, only_field_types: %w[text]) + submission = create(:submission, template:) + submitter = create(:submitter, submission:, uuid: template.submitters.first['uuid'], account:) + + visit submit_form_path(slug: submitter.slug) + + expect(page).to have_content(Wabosign::PRODUCT_NAME) + end + + it 'renders the upstream powered-by attribution on the start form' do + template = create(:template, shared_link: true, account:, author: user, + except_field_types: %w[phone payment stamp]) + + visit start_form_path(slug: template.slug) + + expect(page).to have_content(I18n.t('powered_by')) + end +end diff --git a/spec/system/role_based_access_spec.rb b/spec/system/role_based_access_spec.rb new file mode 100644 index 00000000..4e4da565 --- /dev/null +++ b/spec/system/role_based_access_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +RSpec.describe 'Role-based access' do + let(:account) { create(:account) } + + describe 'admin role' do + let(:admin) { create(:user, account:, role: User::ADMIN_ROLE) } + + before do + sign_in(admin) + end + + it 'shows Create and Upload buttons on the dashboard' do + visit templates_path + + expect(page).to have_link('Create') + expect(page).to have_content(I18n.t('upload')) + end + + it 'shows all settings nav items' do + visit settings_personalization_path + + within('#account_settings_menu') do + expect(page).to have_link(I18n.t('personalization')) + expect(page).to have_link(I18n.t('users')) + expect(page).to have_link(I18n.t('notifications')) + expect(page).to have_link(I18n.t('e_signature')) + end + end + + it 'shows Edit, Clone, and Archive on a template' do + template = create(:template, account:, author: admin) + + visit template_path(template) + + expect(page).to have_content(template.name) + expect(page).to have_link('Edit') + expect(page).to have_link('Clone') + expect(page).to have_button('Archive') + end + end + + describe 'editor role' do + let(:editor) { create(:user, account:, role: User::EDITOR_ROLE) } + + before do + sign_in(editor) + end + + it 'shows Create and Upload buttons on the dashboard' do + visit templates_path + + expect(page).to have_link('Create') + expect(page).to have_content(I18n.t('upload')) + end + + it 'shows the Users nav item' do + visit settings_personalization_path + + within('#account_settings_menu') do + expect(page).to have_link(I18n.t('users')) + end + end + + it 'shows limited settings nav items (no email/esign)' do + visit settings_personalization_path + + within('#account_settings_menu') do + expect(page).to have_link(I18n.t('personalization')) + expect(page).to have_link(I18n.t('notifications')) + expect(page).to have_link(I18n.t('users')) + + expect(page).not_to have_link(I18n.t('e_signature')) + end + end + + it 'shows Edit, Clone, and Archive on a template' do + template = create(:template, account:, author: editor) + + visit template_path(template) + + expect(page).to have_content(template.name) + expect(page).to have_link('Edit') + expect(page).to have_link('Clone') + expect(page).to have_button('Archive') + end + + it 'can view personalization but cannot modify brand_name' do + visit settings_personalization_path + + expect(page).to have_field('brand_name') + + fill_in 'brand_name', with: 'Editor Brand' + click_button 'Save' + + expect(account.reload.brand_name).not_to eq('Editor Brand') + end + end + + describe 'viewer role' do + let(:viewer) { create(:user, account:, role: User::VIEWER_ROLE) } + + before do + sign_in(viewer) + end + + it 'does NOT show Create or Upload buttons on the dashboard' do + visit templates_path + + expect(page).not_to have_link('Create') + end + + it 'does not have access to users settings' do + visit settings_users_path + + expect(current_path).to eq(root_path) + end + + it 'shows personalization and notifications in nav' do + visit settings_personalization_path + + within('#account_settings_menu') do + expect(page).to have_link(I18n.t('personalization')) + expect(page).to have_link(I18n.t('notifications')) + end + end + + it 'can view a template but does not see Edit, Clone, or Archive' do + template = create(:template, account:, author: create(:user, account:)) + + visit template_path(template) + + expect(page).to have_content(template.name) + expect(page).not_to have_link('Edit') + expect(page).not_to have_link('Clone') + expect(page).not_to have_button('Archive') + end + + it 'can view personalization page but cannot modify brand_name' do + visit settings_personalization_path + + expect(page).to have_field('brand_name') + + fill_in 'brand_name', with: 'Viewer Brand' + click_button 'Save' + + expect(account.reload.brand_name).not_to eq('Viewer Brand') + end + end +end diff --git a/spec/system/signing_flow_edge_cases_spec.rb b/spec/system/signing_flow_edge_cases_spec.rb new file mode 100644 index 00000000..8e6a1be9 --- /dev/null +++ b/spec/system/signing_flow_edge_cases_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +RSpec.describe 'Signing Flow Edge Cases' do + let(:account) { create(:account) } + let(:author) { create(:user, account:) } + + describe 'enforced signing order' do + let(:template) do + create(:template, submitter_count: 2, account:, author:, only_field_types: %w[text]) + end + let(:submission) { create(:submission, template:, template_fields: template.fields, template_submitters: template.submitters) } + let(:first_submitter) do + create(:submitter, submission:, uuid: template.submitters[0]['uuid'], account:, + email: 'first@example.com') + end + let(:second_submitter) do + create(:submitter, submission:, uuid: template.submitters[1]['uuid'], account:, + email: 'second@example.com') + end + + pending 'prevents the second signer from filling before the first is done' do + create(:account_config, account:, key: AccountConfig::ENFORCE_SIGNING_ORDER_KEY, value: true) + + visit submit_form_path(slug: second_submitter.slug) + + expect(page).to have_content('Awaiting completion by the other party') + expect(page).not_to have_selector('#submit_form_button') + end + + it 'allows the second signer to fill after the first completes' do + visit submit_form_path(slug: first_submitter.slug) + fill_in 'First Name', with: 'Alice' + find('#submit_form_button').click + expect(page).to have_content('Form has been completed!') + + visit submit_form_path(slug: second_submitter.slug) + fill_in 'First Name', with: 'Bob' + find('#submit_form_button').click + expect(page).to have_content('Form has been completed!') + + second_submitter.reload + expect(second_submitter.completed_at).to be_present + end + end + + describe 'signature pad clear and redraw' do + let(:template) { create(:template, account:, author:, only_field_types: %w[signature]) } + let(:submission) { create(:submission, template:) } + let(:submitter) do + create(:submitter, submission:, uuid: template.submitters.first['uuid'], account:) + end + + it 'completes after clearing and redrawing the signature' do + visit submit_form_path(slug: submitter.slug) + + find('#expand_form_button').click + draw_canvas + click_button 'Clear' + draw_canvas + click_button 'Sign and Complete' + + expect(page).to have_content('Document has been signed!') + + submitter.reload + expect(submitter.completed_at).to be_present + expect(field_value(submitter, 'Signature')).to be_present + end + end + + describe 'resubmit flow' do + let(:template) do + create(:template, shared_link: true, account:, author:, only_field_types: %w[text]) + end + + before do + create(:account_config, account:, key: AccountConfig::ALLOW_TO_RESUBMIT, value: true) + end + + pending 'allows a submitter to resubmit after completing' do + visit start_form_path(slug: template.slug) + + fill_in 'Email', with: 'john@example.com' + click_button 'Start' + + fill_in 'First Name', with: 'John' + find('#submit_form_button').click + + expect(page).to have_content('Form has been completed!') + + visit start_form_path(slug: template.slug) + + fill_in 'Email', with: 'john@example.com' + click_button 'Start' + + expect(page).not_to have_content('already completed') + + fill_in 'First Name', with: 'John Updated' + find('#submit_form_button').click + + expect(page).to have_content('Form has been completed!') + end + end + + describe 'all optional fields' do + let(:template) { create(:template, account:, author:, only_field_types: %w[text date]) } + let(:submission) { create(:submission, template:) } + let(:submitter) do + create(:submitter, submission:, uuid: template.submitters.first['uuid'], account:) + end + + before do + fields = template.fields.map { |f| f.dup.tap { |h| h['required'] = false } } + template.update!(fields:) + submission.update!(template_fields: fields) + end + + pending 'completes with the header complete button without filling any fields' do + visit submit_form_path(slug: submitter.slug) + + find('#expand_form_button').click + + expect(page).to have_css('#complete_button_container') + + page.execute_script('document.getElementById("complete_form_button")?.click() || document.querySelector(".complete-button")?.click()') + + expect(page).to have_content('Form has been completed!') + + submitter.reload + expect(submitter.completed_at).to be_present + end + end + + describe 'decline with custom reason' do + let(:template) { create(:template, account:, author:, only_field_types: %w[text]) } + let(:submission) { create(:submission, template:) } + let(:submitter) do + create(:submitter, submission:, uuid: template.submitters.first['uuid'], account:) + end + + it 'records the decline reason on the submission event' do + visit submit_form_path(slug: submitter.slug) + + find('#decline_button').click + fill_in 'reason', with: 'I do not agree with the terms and conditions' + within('dialog[open]') { click_button 'Decline' } + + expect(page).to have_content('Form has been declined') + + submitter.reload + expect(submitter.declined_at).to be_present + + event = submission.submission_events.find_by(submitter:, event_type: 'decline_form') + expect(event).to be_present + expect(event.data).to include('reason' => 'I do not agree with the terms and conditions') + end + end +end diff --git a/spec/system/sms_settings_spec.rb b/spec/system/sms_settings_spec.rb new file mode 100644 index 00000000..ad862525 --- /dev/null +++ b/spec/system/sms_settings_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +RSpec.describe 'SMS Settings' do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + + before do + sign_in(user) + end + + it 'shows the SMS settings page with a placeholder in non-multitenant mode' do + visit settings_sms_path + + expect(page).to have_content('SMS') + expect(page).to have_content(I18n.t('unlock_with_docuseal_pro')) + end +end diff --git a/spec/system/sso_settings_spec.rb b/spec/system/sso_settings_spec.rb new file mode 100644 index 00000000..d0da7303 --- /dev/null +++ b/spec/system/sso_settings_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +RSpec.describe 'SSO Settings' do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + + before do + sign_in(user) + end + + it 'shows a placeholder in single-tenant mode' do + visit settings_sso_index_path + + expect(page).to have_content(I18n.t('unlock_with_docuseal_pro')) + end +end diff --git a/spec/system/submission_lifecycle_spec.rb b/spec/system/submission_lifecycle_spec.rb new file mode 100644 index 00000000..8d231391 --- /dev/null +++ b/spec/system/submission_lifecycle_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +RSpec.describe 'Submission Lifecycle' do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + + before do + sign_in(user) + end + + it 'shows pending submissions in the template page and completed after signing', sidekiq: :inline do + create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY, + value: GenerateCertificate.call.transform_values(&:to_pem)) + + template = create(:template, account:, author: user, only_field_types: %w[text]) + submission = create(:submission, template:) + submitter = create(:submitter, submission:, uuid: template.submitters.first['uuid'], account:, + email: 'signer@example.com', name: nil) + + visit template_path(template) + + expect(page).to have_content('signer@example.com') + expect(page).to have_content('AWAITING') + + visit submit_form_path(slug: submitter.slug) + + fill_in 'First Name', with: 'Alice' + find('#submit_form_button').click + + expect(page).to have_content('Form has been completed!') + + visit template_path(template) + + expect(page).to have_content('signer@example.com') + expect(page).to have_content('COMPLETED') + + submitter.reload + expect(submitter.completed_at).to be_present + expect(submitter.ip).to eq('127.0.0.1') + end + + it 'creates a submission via the send-to-recipients modal and shows it as pending' do + template = create(:template, account:, author: user, only_field_types: %w[text]) + + visit template_path(template) + + click_link 'Send to Recipients', visible: :all + + within('#modal') do + find('textarea[name="emails"]').set('recipient@example.com') + click_button 'Add Recipients' + end + + expect(page).to have_content('recipient@example.com') + expect(page).to have_content('AWAITING') + + submission = template.submissions.last + expect(submission).to be_present + expect(submission.submitters.first.email).to eq('recipient@example.com') + end +end diff --git a/spec/system/template_crud_edge_cases_spec.rb b/spec/system/template_crud_edge_cases_spec.rb new file mode 100644 index 00000000..61df88b7 --- /dev/null +++ b/spec/system/template_crud_edge_cases_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +RSpec.describe 'Template CRUD Edge Cases' do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + + before do + sign_in(user) + end + + describe 'restoring an archived template' do + let!(:template) { create(:template, account:, author: user, archived_at: Time.current, + except_field_types: %w[phone payment]) } + + it 'restores an archived template from the template page' do + visit template_path(template) + + expect(page).to have_content('Archived') + page.find('form[action*="restore"]').click + + expect(template.reload.archived_at).to be_nil + end + + pending 'lists archived templates on the dedicated index page' do + visit templates_archived_index_path + + expect(page).to have_content(template.name) + find('button[aria-label]').click + + expect(page).not_to have_content(template.name) + end + end + + describe 'template share link' do + let!(:template) { create(:template, account:, author: user, except_field_types: %w[phone payment]) } + + it 'opens the share link modal' do + visit template_path(template) + + click_link 'Link' + + expect(page).to have_field('embedding_url', with: /#{template.slug}/) + end + + it 'enables and disables the share link' do + visit template_share_link_path(template) + + check 'template_shared_link' + page.execute_script('document.getElementById("shared_link_form").submit()') + + expect(template.reload.shared_link).to be true + + visit template_share_link_path(template) + + uncheck 'template_shared_link' + page.execute_script('document.getElementById("shared_link_form").submit()') + + expect(template.reload.shared_link).to be false + end + end + + describe 'template folder management' do + it 'navigates through folders on the dashboard' do + folder = create(:template_folder, account:, author: user, name: 'My Folder') + template_in_folder = create(:template, account:, author: user, folder:, + except_field_types: %w[phone payment]) + template_root = create(:template, account:, author: user, name: 'Root Template', + except_field_types: %w[phone payment]) + + visit folder_path(folder) + + expect(page).to have_content(folder.name) + expect(page).to have_content(template_in_folder.name) + expect(page).not_to have_content(template_root.name) + end + end +end