Skip to content

Commit 6242c7c

Browse files
committed
feat(api): add product_catalog gating concerns
## Context The product_catalog flag needs guards so that, per organization, the new catalog API is reachable only when enabled and the legacy pricing API is blocked once enabled. The same rule applies to REST and GraphQL. ## Description Add four concerns built on organization.product_catalog_enabled?: Api::RequiresProductCatalog and RequiresProductCatalog forbid the new catalog endpoints/mutations unless the flag is on; Api::ForbidsLegacyBilling and ForbidsLegacyBilling block legacy pricing writes (create/update/destroy only, reads stay open) once the flag is on. REST uses a before_action, GraphQL the ready? hook, both returning forbidden_error with distinct codes (feature_unavailable / legacy_billing_disabled). Wiring into controllers and mutations comes next.
1 parent 5ae1751 commit 6242c7c

8 files changed

Lines changed: 285 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
# Blocks writes to legacy pricing REST endpoints once an organization is on
4+
# the product catalog. Reads stay open so already-billed data remains
5+
# accessible during and after migration.
6+
module Api
7+
module ForbidsLegacyBilling
8+
extend ActiveSupport::Concern
9+
10+
included do
11+
# The write actions live on the host controllers, not this concern.
12+
before_action :forbid_legacy_billing!, only: %i[create update destroy] # rubocop:disable Rails/LexicallyScopedActionFilter
13+
end
14+
15+
private
16+
17+
def forbid_legacy_billing!
18+
forbidden_error(code: "legacy_billing_disabled") if current_organization&.product_catalog_enabled?
19+
end
20+
end
21+
end
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# frozen_string_literal: true
2+
3+
# Guards new product-catalog (v2) REST endpoints: only organizations with the
4+
# product_catalog premium integration may use them.
5+
module Api
6+
module RequiresProductCatalog
7+
extend ActiveSupport::Concern
8+
9+
included do
10+
before_action :ensure_product_catalog!
11+
end
12+
13+
private
14+
15+
def ensure_product_catalog!
16+
forbidden_error(code: "feature_unavailable") unless current_organization&.product_catalog_enabled?
17+
end
18+
end
19+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
# Blocks legacy pricing GraphQL mutations once an organization is on the
4+
# product catalog.
5+
module ForbidsLegacyBilling
6+
def ready?(**args)
7+
raise forbidden_error(code: "legacy_billing_disabled") if current_organization&.product_catalog_enabled?
8+
9+
super
10+
end
11+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
# Guards new product-catalog (v2) GraphQL mutations/resolvers: only
4+
# organizations with the product_catalog premium integration may use them.
5+
module RequiresProductCatalog
6+
def ready?(**args)
7+
raise forbidden_error(code: "feature_unavailable") unless current_organization&.product_catalog_enabled?
8+
9+
super
10+
end
11+
end
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe Api::ForbidsLegacyBilling do
6+
include ApiHelper
7+
8+
# rubocop:disable RSpec/DescribedClass
9+
controller(ApplicationController) do
10+
include ApiErrors
11+
include Api::ForbidsLegacyBilling
12+
13+
attr_reader :current_organization
14+
15+
def create
16+
render json: {ok: true}
17+
end
18+
19+
def update
20+
render json: {ok: true}
21+
end
22+
23+
def destroy
24+
render json: {ok: true}
25+
end
26+
27+
def index
28+
render json: {ok: true}
29+
end
30+
end
31+
# rubocop:enable RSpec/DescribedClass
32+
33+
let(:organization) { create(:organization, premium_integrations:) }
34+
let(:premium_integrations) { [] }
35+
36+
before { allow(controller).to receive(:current_organization).and_return(organization) }
37+
38+
context "when the organization is on the product catalog", :premium do
39+
let(:premium_integrations) { ["product_catalog"] }
40+
41+
it "blocks writes" do
42+
post :create
43+
44+
expect(response).to have_http_status(:forbidden)
45+
expect(json[:code]).to eq("legacy_billing_disabled")
46+
end
47+
48+
it "still allows reads" do
49+
get :index
50+
51+
expect(response).to have_http_status(:success)
52+
end
53+
end
54+
55+
context "when the organization is not on the product catalog" do
56+
it "allows writes" do
57+
post :create
58+
59+
expect(response).to have_http_status(:success)
60+
end
61+
end
62+
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe Api::RequiresProductCatalog do
6+
include ApiHelper
7+
8+
# rubocop:disable RSpec/DescribedClass
9+
controller(ApplicationController) do
10+
include ApiErrors
11+
include Api::RequiresProductCatalog
12+
13+
attr_reader :current_organization
14+
15+
def index
16+
render json: {ok: true}
17+
end
18+
end
19+
# rubocop:enable RSpec/DescribedClass
20+
21+
let(:organization) { create(:organization, premium_integrations:) }
22+
let(:premium_integrations) { [] }
23+
24+
before { allow(controller).to receive(:current_organization).and_return(organization) }
25+
26+
context "when the organization is not on the product catalog" do
27+
it "returns a forbidden error" do
28+
get :index
29+
30+
expect(response).to have_http_status(:forbidden)
31+
expect(json[:code]).to eq("feature_unavailable")
32+
end
33+
end
34+
35+
context "when the organization is on the product catalog", :premium do
36+
let(:premium_integrations) { ["product_catalog"] }
37+
38+
it "allows the request" do
39+
get :index
40+
41+
expect(response).to have_http_status(:success)
42+
expect(json[:ok]).to be(true)
43+
end
44+
end
45+
end
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
module ForbidsLegacyBillingSpec
6+
class ThingType < Types::BaseObject
7+
field :ok, Boolean, null: false
8+
end
9+
10+
class GuardedMutation < Mutations::BaseMutation
11+
include ForbidsLegacyBilling
12+
13+
graphql_name "ForbiddenWhenProductCatalog"
14+
type ThingType
15+
16+
def current_organization
17+
context[:current_organization]
18+
end
19+
20+
def resolve(**args)
21+
{ok: true}
22+
end
23+
end
24+
25+
class MutationType < Types::BaseObject
26+
field :legacy, mutation: GuardedMutation
27+
end
28+
29+
class TestSchema < LagoApiSchema
30+
mutation(MutationType)
31+
end
32+
end
33+
34+
RSpec.describe ForbidsLegacyBilling do
35+
subject(:result) do
36+
ForbidsLegacyBillingSpec::TestSchema.execute(
37+
"mutation { legacy(input: {}) { ok } }",
38+
context: {current_organization: organization}
39+
)
40+
end
41+
42+
let(:organization) { create(:organization, premium_integrations:) }
43+
let(:premium_integrations) { [] }
44+
45+
context "when the organization is on the product catalog", :premium do
46+
let(:premium_integrations) { ["product_catalog"] }
47+
48+
it "blocks the legacy mutation" do
49+
expect(result["errors"].first["extensions"]["code"]).to eq("legacy_billing_disabled")
50+
end
51+
end
52+
53+
context "when the organization is not on the product catalog" do
54+
it "allows the legacy mutation" do
55+
expect(result["data"]["legacy"]["ok"]).to be(true)
56+
end
57+
end
58+
end
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
module RequiresProductCatalogSpec
6+
class ThingType < Types::BaseObject
7+
field :ok, Boolean, null: false
8+
end
9+
10+
class GuardedMutation < Mutations::BaseMutation
11+
include RequiresProductCatalog
12+
13+
graphql_name "GuardedByProductCatalog"
14+
type ThingType
15+
16+
def current_organization
17+
context[:current_organization]
18+
end
19+
20+
def resolve(**args)
21+
{ok: true}
22+
end
23+
end
24+
25+
class MutationType < Types::BaseObject
26+
field :guarded, mutation: GuardedMutation
27+
end
28+
29+
class TestSchema < LagoApiSchema
30+
mutation(MutationType)
31+
end
32+
end
33+
34+
RSpec.describe RequiresProductCatalog do
35+
subject(:result) do
36+
RequiresProductCatalogSpec::TestSchema.execute(
37+
"mutation { guarded(input: {}) { ok } }",
38+
context: {current_organization: organization}
39+
)
40+
end
41+
42+
let(:organization) { create(:organization, premium_integrations:) }
43+
let(:premium_integrations) { [] }
44+
45+
context "when the organization is not on the product catalog" do
46+
it "returns a forbidden error" do
47+
expect(result["errors"].first["extensions"]["code"]).to eq("feature_unavailable")
48+
end
49+
end
50+
51+
context "when the organization is on the product catalog", :premium do
52+
let(:premium_integrations) { ["product_catalog"] }
53+
54+
it "allows the mutation" do
55+
expect(result["data"]["guarded"]["ok"]).to be(true)
56+
end
57+
end
58+
end

0 commit comments

Comments
 (0)