From 56ba49bce2218fbd9ba45bfbc97d2151e7cb947b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Romain=20Semp=C3=A9?= Date: Mon, 14 Sep 2026 17:37:23 +0200 Subject: [PATCH 1/2] feat(catalog): enforce a slug-safe code on v2 catalog objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context No Lago model validated code format, so every code-keyed v2 route carries a greedy `code: /.*/` constraint. A product coded `a/b` collides with nested routes (e.g. `/products/a/filters` resolves to the filters index), making it unreachable on member routes — and other slashed codes shadow-collide with current or future nested segments. The catalog tables are still empty, so this is the only window to add a format rule. ## Description Introduce a `CatalogCodeFormat` concern enforcing a slug-safe code (`/\A(?!\.+\z)[a-zA-Z0-9_\-.]+\z/`: letters, digits, underscore, hyphen, dot; the negative lookahead also rejects an all-dot code like `.` or `..`, which proxies normalize and cannot be addressed). It is included in every code-keyed v2 catalog model — Product, ProductCategory, ProductFilter, RateCard, RateCardRate, CatalogPlan, RatePhase. allow_blank leaves the empty case to the existing presence validation, and the failure is a standard `code` field error (value_is_invalid), so REST and GraphQL surface it on the field the caller sent. No data migration — the tables are empty. --- app/models/catalog_plan.rb | 1 + app/models/concerns/catalog_code_format.rb | 19 +++++++++++ app/models/product.rb | 1 + app/models/product_category.rb | 1 + app/models/product_filter.rb | 1 + app/models/rate_card.rb | 1 + app/models/rate_card_rate.rb | 1 + app/models/rate_phase.rb | 1 + spec/models/catalog_plan_spec.rb | 2 ++ spec/models/product_category_spec.rb | 2 ++ spec/models/product_filter_spec.rb | 2 ++ spec/models/product_spec.rb | 2 ++ spec/models/rate_card_rate_spec.rb | 2 ++ spec/models/rate_card_spec.rb | 2 ++ spec/models/rate_phase_spec.rb | 2 ++ .../shared_examples/catalog_code_format.rb | 33 +++++++++++++++++++ 16 files changed, 73 insertions(+) create mode 100644 app/models/concerns/catalog_code_format.rb create mode 100644 spec/support/shared_examples/catalog_code_format.rb diff --git a/app/models/catalog_plan.rb b/app/models/catalog_plan.rb index df411c94395..07cd7ff149a 100644 --- a/app/models/catalog_plan.rb +++ b/app/models/catalog_plan.rb @@ -7,6 +7,7 @@ class CatalogPlan < ApplicationRecord include PaperTrailTraceable include Currencies include Discard::Model + include CatalogCodeFormat self.discard_column = :deleted_at diff --git a/app/models/concerns/catalog_code_format.rb b/app/models/concerns/catalog_code_format.rb new file mode 100644 index 00000000000..80e80ec6dfd --- /dev/null +++ b/app/models/concerns/catalog_code_format.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Enforces a slug-safe `code` on v2 catalog objects (Product, ProductCategory, +# ProductFilter, RateCard, RateCardRate, CatalogPlan, RatePhase). Codes are +# member-route params, so restricting them to a URL-safe charset keeps every +# code addressable and avoids collisions with nested route segments. The +# leading negative lookahead rejects an all-dot code (`.`, `..`, …): those are +# path-segment specials that clients and proxies normalize away, so they are +# not reliably addressable. allow_blank leaves the empty case to the presence +# validation. +module CatalogCodeFormat + extend ActiveSupport::Concern + + CODE_FORMAT = /\A(?!\.+\z)[a-zA-Z0-9_\-.]+\z/ + + included do + validates :code, format: {with: CODE_FORMAT}, allow_blank: true + end +end diff --git a/app/models/product.rb b/app/models/product.rb index 2b399eb6c69..d3037820b03 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -4,6 +4,7 @@ class Product < ApplicationRecord include PaperTrailTraceable include Discard::Model include CatalogAttachable + include CatalogCodeFormat self.discard_column = :deleted_at diff --git a/app/models/product_category.rb b/app/models/product_category.rb index e5b8bb150c5..49dfcf86f3f 100644 --- a/app/models/product_category.rb +++ b/app/models/product_category.rb @@ -4,6 +4,7 @@ class ProductCategory < ApplicationRecord include PaperTrailTraceable include Discard::Model include CatalogAttachable + include CatalogCodeFormat self.discard_column = :deleted_at diff --git a/app/models/product_filter.rb b/app/models/product_filter.rb index f8dfc0bbd7a..69d84081438 100644 --- a/app/models/product_filter.rb +++ b/app/models/product_filter.rb @@ -3,6 +3,7 @@ class ProductFilter < ApplicationRecord include PaperTrailTraceable include Discard::Model + include CatalogCodeFormat self.discard_column = :deleted_at diff --git a/app/models/rate_card.rb b/app/models/rate_card.rb index 921d3d3ad51..28fbf730d2f 100644 --- a/app/models/rate_card.rb +++ b/app/models/rate_card.rb @@ -5,6 +5,7 @@ class RateCard < ApplicationRecord include Currencies include Discard::Model include CatalogAttachable + include CatalogCodeFormat self.discard_column = :deleted_at diff --git a/app/models/rate_card_rate.rb b/app/models/rate_card_rate.rb index 93b99524297..6ecc42bff93 100644 --- a/app/models/rate_card_rate.rb +++ b/app/models/rate_card_rate.rb @@ -4,6 +4,7 @@ class RateCardRate < ApplicationRecord include PaperTrailTraceable include ChargePropertiesValidation include Discard::Model + include CatalogCodeFormat self.discard_column = :deleted_at diff --git a/app/models/rate_phase.rb b/app/models/rate_phase.rb index bc152c5fb34..b0e652b7b5a 100644 --- a/app/models/rate_phase.rb +++ b/app/models/rate_phase.rb @@ -3,6 +3,7 @@ class RatePhase < ApplicationRecord include PaperTrailTraceable include Discard::Model + include CatalogCodeFormat self.discard_column = :deleted_at diff --git a/spec/models/catalog_plan_spec.rb b/spec/models/catalog_plan_spec.rb index a1dff6efc1b..03d16adf16e 100644 --- a/spec/models/catalog_plan_spec.rb +++ b/spec/models/catalog_plan_spec.rb @@ -7,6 +7,8 @@ it_behaves_like "paper_trail traceable" + it_behaves_like "a catalog code", :catalog_plan + describe "associations" do it do expect(catalog_plan).to belong_to(:organization) diff --git a/spec/models/product_category_spec.rb b/spec/models/product_category_spec.rb index 628dbd294ec..13bbeb5dde1 100644 --- a/spec/models/product_category_spec.rb +++ b/spec/models/product_category_spec.rb @@ -7,6 +7,8 @@ it_behaves_like "paper_trail traceable" + it_behaves_like "a catalog code", :product_category + describe "associations" do it do expect(product_category).to belong_to(:organization) diff --git a/spec/models/product_filter_spec.rb b/spec/models/product_filter_spec.rb index 3de40fe35b1..9833081e686 100644 --- a/spec/models/product_filter_spec.rb +++ b/spec/models/product_filter_spec.rb @@ -7,6 +7,8 @@ it_behaves_like "paper_trail traceable" + it_behaves_like "a catalog code", :product_filter + describe "associations" do it do expect(product_filter).to belong_to(:organization) diff --git a/spec/models/product_spec.rb b/spec/models/product_spec.rb index ad99f438083..ca144160ce7 100644 --- a/spec/models/product_spec.rb +++ b/spec/models/product_spec.rb @@ -7,6 +7,8 @@ it_behaves_like "paper_trail traceable" + it_behaves_like "a catalog code", :product + describe "enums" do it do expect(product).to define_enum_for(:product_type) diff --git a/spec/models/rate_card_rate_spec.rb b/spec/models/rate_card_rate_spec.rb index a43ca06d478..fd34315bf86 100644 --- a/spec/models/rate_card_rate_spec.rb +++ b/spec/models/rate_card_rate_spec.rb @@ -7,6 +7,8 @@ it_behaves_like "paper_trail traceable" + it_behaves_like "a catalog code", :rate_card_rate + describe "enums" do it do expect(rate_card_rate).to define_enum_for(:rate_model) diff --git a/spec/models/rate_card_spec.rb b/spec/models/rate_card_spec.rb index 14e9e586532..132885093ca 100644 --- a/spec/models/rate_card_spec.rb +++ b/spec/models/rate_card_spec.rb @@ -7,6 +7,8 @@ it_behaves_like "paper_trail traceable" + it_behaves_like "a catalog code", :rate_card + describe "enums" do it do expect(rate_card).to define_enum_for(:billing_timing) diff --git a/spec/models/rate_phase_spec.rb b/spec/models/rate_phase_spec.rb index 00702964893..92e03c1d647 100644 --- a/spec/models/rate_phase_spec.rb +++ b/spec/models/rate_phase_spec.rb @@ -7,6 +7,8 @@ it_behaves_like "paper_trail traceable" + it_behaves_like "a catalog code", :rate_phase + describe "associations" do it do expect(rate_phase).to belong_to(:organization) diff --git a/spec/support/shared_examples/catalog_code_format.rb b/spec/support/shared_examples/catalog_code_format.rb new file mode 100644 index 00000000000..4ac5b162cdf --- /dev/null +++ b/spec/support/shared_examples/catalog_code_format.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Shared validation for the slug-safe `code` on v2 catalog objects. +# Usage: it_behaves_like "a catalog code", :product +RSpec.shared_examples "a catalog code" do |factory| + describe "code format" do + it "allows a slug-safe code" do + record = build(factory, code: "valid_code-1.2") + record.valid? + expect(record.errors.where(:code, :invalid)).to be_empty + end + + it "rejects a code containing a slash" do + record = build(factory, code: "a/b") + record.valid? + expect(record.errors.where(:code, :invalid)).to be_present + end + + it "rejects a code containing a space" do + record = build(factory, code: "a b") + record.valid? + expect(record.errors.where(:code, :invalid)).to be_present + end + + it "rejects the path-segment specials . and .." do + %w[. ..].each do |code| + record = build(factory, code:) + record.valid? + expect(record.errors.where(:code, :invalid)).to be_present + end + end + end +end From 8c87510bfe67154be2c5d60b97b25eeb274f39c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Romain=20Semp=C3=A9?= Date: Tue, 15 Sep 2026 17:19:21 +0200 Subject: [PATCH 2/2] test(catalog): assert a valid code has no code error at all Per review: check record.errors[:code] rather than only the :invalid type on the valid-code case, so it also catches a valid code that trips any other code validation. The rejection cases keep the :invalid filter to prove the format rule specifically fired. --- spec/support/shared_examples/catalog_code_format.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/support/shared_examples/catalog_code_format.rb b/spec/support/shared_examples/catalog_code_format.rb index 4ac5b162cdf..c0498d8bae4 100644 --- a/spec/support/shared_examples/catalog_code_format.rb +++ b/spec/support/shared_examples/catalog_code_format.rb @@ -7,7 +7,7 @@ it "allows a slug-safe code" do record = build(factory, code: "valid_code-1.2") record.valid? - expect(record.errors.where(:code, :invalid)).to be_empty + expect(record.errors[:code]).to be_empty end it "rejects a code containing a slash" do