Files
Kabiru Mwenja 5e3dd6a1d1 fix(documents): strip invisible characters from document titles
Documents created with zero-width Unicode characters (e.g. U+200B)
in their titles become unclickable on the index page, making them
hard to manage or delete.

Introduce RemoveInvisibleCharacters normalizer, replacing the former
RemoveAsciiControlCharacters. It strips both ASCII control characters
and Unicode zero-width characters, with each category defined as a
named constant for clarity. Apply it to Document#title and update
existing callers (Project#identifier, CustomField#name).

Add a shared RSpec example "strips invisible characters" to verify
normalization consistently across all three models.
2026-03-31 18:18:41 +03:00

738 lines
20 KiB
Ruby

# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "spec_helper"
RSpec.describe CustomField do
before do
described_class.destroy_all
end
let(:field) { build(:custom_field) }
let(:field2) { build(:custom_field) }
it { is_expected.to have_readonly_attribute(:field_format) }
describe "#name" do
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_length_of(:name).is_at_most(256) }
it_behaves_like "strips invisible characters", :name
describe "uniqueness" do
describe "WHEN value, locale and type are identical" do
before do
field.name = field2.name = "taken name"
field2.save!
end
it { expect(field).not_to be_valid }
end
describe "WHEN value and locale are identical and type is different" do
before do
field.name = field2.name = "taken name"
field2.save!
field.type = "TestCustomField"
end
it { expect(field).to be_valid }
end
describe "WHEN type and locale are identical and value is different" do
before do
field.name = "new name"
field2.name = "taken name"
field2.save!
end
it { expect(field).to be_valid }
end
end
end
describe "#valid?" do
describe "WITH a text field WITH minimum length blank" do
before do
field.field_format = "text"
field.min_length = nil
end
it { expect(field).not_to be_valid }
end
describe "WITH a text field WITH maximum length blank" do
before do
field.field_format = "text"
field.max_length = nil
end
it { expect(field).not_to be_valid }
end
describe "WITH a text field WITH minimum length not an integer" do
before do
field.field_format = "text"
field.min_length = "a"
end
it { expect(field).not_to be_valid }
end
describe "WITH a text field WITH maximum length not an integer" do
before do
field.field_format = "text"
field.max_length = "a"
end
it { expect(field).not_to be_valid }
end
describe "WITH a text field WITH minimum length greater than maximum length" do
before do
field.field_format = "text"
field.min_length = 2
field.max_length = 1
end
it { expect(field).not_to be_valid }
end
describe "WITH a text field WITH negative minimum length" do
before do
field.field_format = "text"
field.min_length = -2
end
it { expect(field).not_to be_valid }
end
describe "WITH a text field WITH negative maximum length" do
before do
field.field_format = "text"
field.max_length = -2
end
it { expect(field).not_to be_valid }
end
describe "WITH a text field WITH an invalid regexp" do
before do
field.field_format = "text"
field.regexp = "[0-9}"
end
it "is not valid" do
expect(field).not_to be_valid
expect(field.errors[:regexp].size).to eq(1)
end
end
describe "WITH a list field WITH a custom option" do
before do
field.field_format = "list"
field.custom_options.build(value: "some value")
end
it "is valid" do
expect(field)
.to be_valid
end
end
end
describe "#all_attribute_names" do
subject { field.all_attribute_names }
context "when field has comments" do
let(:field) { build_stubbed(:custom_field, :has_comment) }
it { is_expected.to eq(["custom_field_#{field.id}", "custom_comment_#{field.id}"]) }
end
context "when field has no comments" do
let(:field) { build_stubbed(:custom_field) }
it { is_expected.to eq(["custom_field_#{field.id}"]) }
end
end
describe "#attribute_name" do
let(:field) { build_stubbed(:custom_field) }
subject { field.attribute_name }
it { is_expected.to eq("custom_field_#{field.id}") }
context "when a format is provided" do
subject { field.attribute_name(:camel_case) }
it { is_expected.to eq("customField#{field.id}") }
end
end
describe "#comment_attribute_name" do
let(:field) { build_stubbed(:custom_field) }
subject { field.comment_attribute_name }
it { is_expected.to eq("custom_comment_#{field.id}") }
context "when a format is provided" do
subject { field.comment_attribute_name(:camel_case) }
it { is_expected.to eq("customComment#{field.id}") }
end
end
describe "#attribute_getter" do
let(:field) { build_stubbed(:custom_field) }
subject { field.attribute_getter }
it { is_expected.to eq(:"custom_field_#{field.id}") }
end
describe "#comment_attribute_getter" do
let(:field) { build_stubbed(:custom_field) }
subject { field.comment_attribute_getter }
it { is_expected.to eq(:"custom_comment_#{field.id}") }
end
describe "#attribute_setter" do
let(:field) { build_stubbed(:custom_field) }
subject { field.attribute_setter }
it { is_expected.to eq(:"custom_field_#{field.id}=") }
end
describe "#comment_attribute_setter" do
let(:field) { build_stubbed(:custom_field) }
subject { field.comment_attribute_setter }
it { is_expected.to eq(:"custom_comment_#{field.id}=") }
end
describe "#column_name" do
let(:field) { build_stubbed(:custom_field) }
subject { field.column_name }
it { is_expected.to eq("cf_#{field.id}") }
end
describe "#possible_values_options" do
let(:project) { build_stubbed(:project) }
let(:user1) { build_stubbed(:user) }
let(:user2) { build_stubbed(:user) }
let(:in_visible_scope) { instance_double(ActiveRecord::Relation) }
let(:principals_scope) { instance_double(ActiveRecord::Relation) }
let(:all_visible_scope) { instance_double(ActiveRecord::Relation) }
context "for a user custom field" do
before do
field.field_format = "user"
allow(project)
.to receive(:principals)
.and_return(principals_scope)
allow(principals_scope)
.to receive(:select)
.and_return([user1, user2])
allow(Principal).to receive_messages(
in_visible_project_or_me: in_visible_scope,
visible: all_visible_scope
)
allow(in_visible_scope).to receive(:select).and_return([user2])
allow(all_visible_scope).to receive(:select).and_return([user1])
end
context "for a project" do
it "is a list of name, id pairs" do
expect(field.possible_values_options(project))
.to contain_exactly([user1.name, user1.id.to_s], [user2.name, user2.id.to_s])
end
end
context "for something that responds to project" do
it "is a list of name, id pairs" do
object = OpenStruct.new(project:) # rubocop:disable Style/OpenStructUse
expect(field.possible_values_options(object))
.to contain_exactly([user1.name, user1.id.to_s], [user2.name, user2.id.to_s])
end
end
context "for nil" do
it "returns all principles visible to me" do
expect(field.possible_values_options)
.to contain_exactly([user2.name, user2.id.to_s])
end
end
context "with user format setting excluding lastname", with_settings: { user_format: :username } do
it "always includes lastname for Group#name{:lastname} aliasing" do
expect(field.possible_values_options)
.to contain_exactly([user2.name, user2.id.to_s])
expect(in_visible_scope).to have_received(:select)
.with("login", "lastname", "id", "type")
end
end
context "for a custom field bound to role assigment" do
let(:project_role) { build_stubbed(:project_role) }
let(:field) { build(:project_custom_field, :user, role_id: project_role.id) }
it "allows all visible users" do
expect(field.possible_values_options).to contain_exactly([user1.name, user1.id.to_s])
end
end
end
context "for a list custom field" do
let(:option1) { build_stubbed(:custom_option) }
let(:option2) { build_stubbed(:custom_option) }
before do
field.field_format = "list"
field.custom_options = [option1, option2]
end
it "is a list of name, id pairs" do
expect(field.possible_values_options)
.to contain_exactly([option1.value, option1.id.to_s], [option2.value, option2.id.to_s])
end
end
context "for a version custom field" do
let(:versions) { [build_stubbed(:version, project:), build_stubbed(:version, project:)] }
let(:shared_versions_scope) { instance_double(ActiveRecord::Relation) }
before do
field.field_format = "version"
allow(shared_versions_scope)
.to receive(:references)
.with(:project)
.and_return(versions)
end
context "with a project provided" do
it "returns the project's shared_versions" do
allow(project)
.to receive(:shared_versions)
.and_return(shared_versions_scope)
expect(field.possible_values_options(project))
.to eql(versions.sort.map { |u| [u.name, u.id.to_s, project.name] })
end
end
context "with a time entry provided" do
let(:time_entry) { build_stubbed(:time_entry, project:) }
it "returns the project's shared_versions" do
allow(project)
.to receive(:shared_versions)
.and_return(shared_versions_scope)
expect(field.possible_values_options(project))
.to eql(versions.sort.map { |u| [u.name, u.id.to_s, project.name] })
end
end
context "with nothing provided" do
context "and no scope provided" do
it "returns the systemwide versions" do
allow(Version)
.to receive(:systemwide)
.and_return(shared_versions_scope)
expect(field.possible_values_options)
.to eql(versions.sort.map { |u| [u.name, u.id.to_s, project.name] })
end
end
context "and scope: :visible is provided" do
it "returns the visible and systemwide versions" do
allow(Version).to receive(:visible).and_return(shared_versions_scope)
allow(shared_versions_scope).to receive(:or)
.with(Version.systemwide)
.and_return(shared_versions_scope)
expect(field.possible_values_options(options: { scope: :visible }))
.to eql(versions.sort.map { |u| [u.name, u.id.to_s, project.name] })
end
end
end
end
end
describe "#possible_values" do
context "on a list custom field" do
let(:field) { described_class.new field_format: "list" }
context "on providing an array" do
before do
field.possible_values = ["One value", "Two values", ""]
end
it "accepts the values" do
expect(field.possible_values.map(&:value))
.to contain_exactly("One value", "Two values")
end
end
context "on providing a string" do
before do
field.possible_values = "One value"
end
it "accepts the values" do
expect(field.possible_values.map(&:value))
.to contain_exactly("One value")
end
end
context "on providing a multiline string" do
before do
field.possible_values = "One value\nTwo values \r\n \n"
end
it "accepts the values" do
expect(field.possible_values.map(&:value))
.to contain_exactly("One value", "Two values")
end
end
end
end
describe "nested attributes for custom options" do
let(:option) { build(:custom_option) }
let(:options) { [option] }
let(:field) { build(:custom_field, field_format: "list", custom_options: options) }
before do
field.save!
end
shared_examples_for "saving updates field's updated_at" do
it "updates updated_at" do
timestamp_before = field.updated_at
sleep 0.001
field.save
expect(field.updated_at).not_to eql(timestamp_before)
end
end
context "after adding a custom option" do
before do
field.attributes = { "custom_options_attributes" => { "0" => option.attributes,
"1" => { value: "blubs" } } }
end
it_behaves_like "saving updates field's updated_at"
end
context "after changing a custom option" do
before do
attributes = option.attributes.merge(value: "new_value")
field.attributes = { "custom_options_attributes" => { "0" => attributes } }
end
it_behaves_like "saving updates field's updated_at"
end
end
describe "#multi_value_possible?" do
context "with a wp list cf" do
let(:field) { build_stubbed(:list_wp_custom_field) }
it "is true" do
expect(field)
.to be_multi_value_possible
end
end
context "with a wp user cf" do
let(:field) { build_stubbed(:user_wp_custom_field) }
it "is true" do
expect(field)
.to be_multi_value_possible
end
end
context "with a wp int cf" do
let(:field) { build_stubbed(:integer_wp_custom_field) }
it "is false" do
expect(field)
.not_to be_multi_value_possible
end
end
context "with a project list cf" do
let(:field) { build_stubbed(:list_project_custom_field) }
it "is true" do
expect(field)
.to be_multi_value_possible
end
end
context "with a project user cf" do
let(:field) { build_stubbed(:user_project_custom_field) }
it "is true" do
expect(field)
.to be_multi_value_possible
end
end
context "with a project int cf" do
let(:field) { build_stubbed(:integer_project_custom_field) }
it "is false" do
expect(field)
.not_to be_multi_value_possible
end
end
context "with a project calculated value cf" do
let(:field) { build_stubbed(:calculated_value_project_custom_field) }
it "is false" do
expect(field)
.not_to be_multi_value_possible
end
end
context "with a time_entry user cf" do
let(:field) { build_stubbed(:time_entry_custom_field, :user) }
it "is true" do
expect(field)
.to be_multi_value_possible
end
end
context "with a time_entry list cf" do
let(:field) { build_stubbed(:time_entry_custom_field, :list) }
it "is true" do
expect(field)
.to be_multi_value_possible
end
end
end
describe "#allow_non_open_versions?" do
context "with a wp list cf" do
let(:field) { build_stubbed(:list_wp_custom_field) }
it "is false" do
expect(field)
.not_to be_allow_non_open_versions_possible
end
end
context "with a wp user cf" do
let(:field) { build_stubbed(:user_wp_custom_field) }
it "is false" do
expect(field)
.not_to be_allow_non_open_versions_possible
end
end
context "with a wp int cf" do
let(:field) { build_stubbed(:integer_wp_custom_field) }
it "is false" do
expect(field)
.not_to be_allow_non_open_versions_possible
end
end
context "with a work package user cf" do
let(:field) { build_stubbed(:wp_custom_field, :user) }
it "is false" do
expect(field)
.not_to be_allow_non_open_versions_possible
end
end
context "with a work package version cf" do
let(:field) { build_stubbed(:wp_custom_field, :version) }
it "is true" do
expect(field)
.to be_allow_non_open_versions_possible
end
end
context "with a version cf for version" do
let(:field) { build_stubbed(:version_custom_field, :version) }
it "is true" do
expect(field)
.to be_allow_non_open_versions_possible
end
end
context "with a project version cf" do
let(:field) { build_stubbed(:project_custom_field, :version) }
it "is true" do
expect(field)
.to be_allow_non_open_versions_possible
end
end
context "with a time entry version cf" do
let(:field) { build_stubbed(:time_entry_custom_field, :version) }
it "is true" do
expect(field)
.to be_allow_non_open_versions_possible
end
end
end
describe "#destroy" do
it "removes the cf" do
field.save!
field.destroy
expect(described_class.where(id: field.id)).not_to exist
end
end
describe "can_have_comment? instance and class methods" do
context "for project custom field" do
let(:instance) { build_stubbed(:project_custom_field) }
context "for instance" do
it { expect(instance).to be_can_have_comment }
end
context "for class" do
it { expect(instance.class).to be_can_have_comment }
end
end
{
wp_custom_field: "work package",
user_custom_field: "user",
version_custom_field: "version",
custom_field: "base"
}.each do |factory, name|
context "for #{name} custom field" do
let(:instance) { build_stubbed(factory) }
context "for instance" do
it { expect(instance).not_to be_can_have_comment }
end
context "for class" do
it { expect(instance.class).not_to be_can_have_comment }
end
end
end
end
describe "#comment_for" do
let(:field) { build_stubbed(:project_custom_field) }
let(:customized) { build_stubbed(:project) }
before { allow(field).to receive(:comments).and_return(comments) }
context "when there are no comments" do
let(:comments) { [] }
it "returns nil" do
expect(field.comment_for(customized)).to be_nil
end
end
context "when comments exist only for other customized" do
let(:comments) { [build_stubbed(:custom_comment, customized: build_stubbed(:project), custom_field: field)] }
it "returns nil" do
expect(field.comment_for(customized)).to be_nil
end
end
context "when comment exists for the customized" do
let(:comment) { build_stubbed(:custom_comment, customized:, custom_field: field) }
let(:other_comment) { build_stubbed(:custom_comment, customized: build_stubbed(:project), custom_field: field) }
let(:comments) { [other_comment, comment] }
it "returns the matching comment" do
expect(field.comment_for(customized)).to eq(comment)
end
end
end
describe "#cast_value" do
describe "handling all registered formats" do
before do
allow(Principal).to receive(:find_by).with(id: 1).and_return(build(:user))
allow(Version).to receive(:find_by).with(id: 1).and_return(build(:version))
allow(CustomField::Hierarchy::Item).to receive(:find_by).with(id: 1).and_return(build(:hierarchy_item))
end
OpenProject::CustomFieldFormat.registered.map(&:name).each do |field_format|
it "handles custom field with format #{field_format}" do
field = build(:custom_field, field_format:)
input = field_format == "date" ? "2025.10.27" : "1"
if field_format == "empty"
expect(field.cast_value(input)).to be_nil
else
expect(field.cast_value(input)).not_to be_nil
end
end
end
end
end
end