diff --git a/app/controllers/api/v1/rules_controller.rb b/app/controllers/api/v1/rules_controller.rb new file mode 100644 index 000000000..2b2d800a8 --- /dev/null +++ b/app/controllers/api/v1/rules_controller.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +class Api::V1::RulesController < Api::V1::BaseController + include Pagy::Backend + + BOOLEAN_FILTERS = { + "true" => true, + "1" => true, + "false" => false, + "0" => false + }.freeze + RESOURCE_TYPES = %w[transaction].freeze + + before_action :ensure_read_scope + before_action :set_rule, only: :show + + def index + return render_invalid_resource_type_filter if invalid_resource_type_filter? + + @per_page = safe_per_page_param + rules_query = current_resource_owner.family.rules + .includes(:actions, conditions: :sub_conditions) + .order(:created_at, :id) + + rules_query = rules_query.where(resource_type: params[:resource_type]) if params[:resource_type].present? + if params[:active].present? + active = parse_boolean_filter(params[:active]) + return if performed? + + rules_query = rules_query.where(active: active) + end + + @pagy, @rules = pagy( + rules_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + end + + def show + render :show + end + + private + + def set_rule + @rule = current_resource_owner.family.rules + .includes(:actions, conditions: :sub_conditions) + .find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def safe_page_param + page = params[:page].to_i + page > 0 ? page : 1 + end + + def safe_per_page_param + per_page = params[:per_page].to_i + case per_page + when 1..100 + per_page + else + 25 + end + end + + def parse_boolean_filter(value) + normalized = value.to_s.downcase + return BOOLEAN_FILTERS[normalized] if BOOLEAN_FILTERS.key?(normalized) + + render json: { + error: "validation_failed", + message: "active must be one of: true, false, 1, 0" + }, status: :unprocessable_entity + nil + end + + def invalid_resource_type_filter? + params[:resource_type].present? && !params[:resource_type].in?(RESOURCE_TYPES) + end + + def render_invalid_resource_type_filter + render json: { + error: "validation_failed", + message: "resource_type must be one of: #{RESOURCE_TYPES.join(", ")}" + }, status: :unprocessable_entity + end +end diff --git a/app/views/api/v1/rules/_action.json.jbuilder b/app/views/api/v1/rules/_action.json.jbuilder new file mode 100644 index 000000000..3dfbab6a1 --- /dev/null +++ b/app/views/api/v1/rules/_action.json.jbuilder @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +json.id action.id +json.action_type action.action_type +json.value action.value +json.created_at action.created_at.iso8601 +json.updated_at action.updated_at.iso8601 diff --git a/app/views/api/v1/rules/_condition.json.jbuilder b/app/views/api/v1/rules/_condition.json.jbuilder new file mode 100644 index 000000000..088028b71 --- /dev/null +++ b/app/views/api/v1/rules/_condition.json.jbuilder @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +json.id condition.id +json.condition_type condition.condition_type +json.operator condition.operator +json.value condition.value + +if condition.compound? + json.sub_conditions condition.sub_conditions do |sub_condition| + json.partial! "api/v1/rules/condition", condition: sub_condition + end +else + json.sub_conditions [] +end + +json.created_at condition.created_at.iso8601 +json.updated_at condition.updated_at.iso8601 diff --git a/app/views/api/v1/rules/_rule.json.jbuilder b/app/views/api/v1/rules/_rule.json.jbuilder new file mode 100644 index 000000000..1009a3737 --- /dev/null +++ b/app/views/api/v1/rules/_rule.json.jbuilder @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +json.id rule.id +json.name rule.name +json.resource_type rule.resource_type +json.active rule.active +json.effective_date rule.effective_date&.iso8601 +json.conditions rule.conditions.select { |condition| condition.parent_id.nil? } do |condition| + json.partial! "api/v1/rules/condition", condition: condition +end +json.actions rule.actions do |action| + json.partial! "api/v1/rules/action", action: action +end +json.created_at rule.created_at.iso8601 +json.updated_at rule.updated_at.iso8601 diff --git a/app/views/api/v1/rules/index.json.jbuilder b/app/views/api/v1/rules/index.json.jbuilder new file mode 100644 index 000000000..97432d8b6 --- /dev/null +++ b/app/views/api/v1/rules/index.json.jbuilder @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +json.data @rules do |rule| + json.partial! "api/v1/rules/rule", rule: rule +end + +json.meta do + json.current_page @pagy.page + json.next_page @pagy.next + json.prev_page @pagy.prev + json.total_pages @pagy.pages + json.total_count @pagy.count + json.per_page @per_page +end diff --git a/app/views/api/v1/rules/show.json.jbuilder b/app/views/api/v1/rules/show.json.jbuilder new file mode 100644 index 000000000..0c4de976c --- /dev/null +++ b/app/views/api/v1/rules/show.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.data do + json.partial! "api/v1/rules/rule", rule: @rule +end diff --git a/config/routes.rb b/config/routes.rb index e6a705346..b68f3c801 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -422,6 +422,7 @@ Rails.application.routes.draw do resources :accounts, only: [ :index, :show ] resources :categories, only: [ :index, :show ] resources :merchants, only: %i[index show] + resources :rules, only: [ :index, :show ] resources :tags, only: %i[index show create update destroy] resources :transactions, only: [ :index, :show, :create, :update, :destroy ] diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index acf51f91c..6edcccac3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -463,6 +463,138 @@ components: type: array items: "$ref": "#/components/schemas/TagDetail" + RuleAction: + type: object + required: + - id + - action_type + - created_at + - updated_at + properties: + id: + type: string + format: uuid + action_type: + type: string + value: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + RuleCondition: + type: object + required: + - id + - condition_type + - operator + - sub_conditions + - created_at + - updated_at + properties: + id: + type: string + format: uuid + condition_type: + type: string + operator: + type: string + value: + type: string + nullable: true + sub_conditions: + type: array + items: + "$ref": "#/components/schemas/RuleCondition" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + Rule: + type: object + required: + - id + - resource_type + - active + - conditions + - actions + - created_at + - updated_at + properties: + id: + type: string + format: uuid + name: + type: string + nullable: true + resource_type: + type: string + enum: + - transaction + active: + type: boolean + effective_date: + type: string + format: date + nullable: true + conditions: + type: array + items: + "$ref": "#/components/schemas/RuleCondition" + actions: + type: array + items: + "$ref": "#/components/schemas/RuleAction" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + RuleResponse: + type: object + required: + - data + properties: + data: + "$ref": "#/components/schemas/Rule" + RuleCollection: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + "$ref": "#/components/schemas/Rule" + meta: + type: object + required: + - current_page + - total_pages + - total_count + - per_page + properties: + current_page: + type: integer + next_page: + type: integer + nullable: true + prev_page: + type: integer + nullable: true + total_pages: + type: integer + total_count: + type: integer + per_page: + type: integer Transfer: type: object required: @@ -2216,6 +2348,105 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/rules": + get: + summary: List rules + tags: + - Rules + security: + - apiKeyAuth: [] + parameters: + - name: page + in: query + required: false + description: 'Page number (default: 1)' + schema: + type: integer + - name: per_page + in: query + required: false + description: 'Items per page (default: 25, max: 100)' + schema: + type: integer + - name: resource_type + in: query + required: false + description: Filter by rule resource type + schema: + type: string + enum: + - transaction + - name: active + in: query + required: false + description: Filter by active status + schema: + type: boolean + responses: + '200': + description: rules listed + content: + application/json: + schema: + "$ref": "#/components/schemas/RuleCollection" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: forbidden - requires read scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: unsupported resource type + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/rules/{id}": + parameters: + - name: id + in: path + required: true + description: Rule ID + schema: + type: string + format: uuid + get: + summary: Retrieve a rule + tags: + - Rules + security: + - apiKeyAuth: [] + responses: + '200': + description: rule retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/RuleResponse" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: forbidden - requires read scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: rule not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/tags": get: summary: List tags diff --git a/spec/requests/api/v1/rules_spec.rb b/spec/requests/api/v1/rules_spec.rb new file mode 100644 index 000000000..0bf8e5bce --- /dev/null +++ b/spec/requests/api/v1/rules_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Rules', type: :request do + let(:family) do + Family.create!( + name: 'API Family', + currency: 'USD', + locale: 'en', + date_format: '%m-%d-%Y' + ) + end + + let(:user) do + family.users.create!( + email: 'api-user@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + end + + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'API Docs Key', + key: key, + scopes: %w[read], + source: 'web' + ) + end + + let(:api_key_without_read_scope) do + key = ApiKey.generate_secure_key + # Valid persisted API keys can only be read/read_write; this intentionally + # bypasses validations to document the runtime insufficient-scope response. + ApiKey.new( + user: user, + name: 'No Read Docs Key', + key: key, + scopes: [], + display_key: key, + source: 'mobile' + ).tap { |api_key| api_key.save!(validate: false) } + end + + let(:'X-Api-Key') { api_key.plain_key } + + let!(:rule) do + family.rules.build( + name: 'Coffee cleanup', + resource_type: 'transaction', + active: true, + effective_date: Date.new(2024, 1, 1) + ).tap do |rule| + rule.conditions.build(condition_type: 'transaction_name', operator: 'like', value: 'coffee') + rule.actions.build(action_type: 'set_transaction_name', value: 'Coffee') + rule.save! + end + end + + path '/api/v1/rules' do + get 'List rules' do + tags 'Rules' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + parameter name: :page, in: :query, type: :integer, required: false, + description: 'Page number (default: 1)' + parameter name: :per_page, in: :query, type: :integer, required: false, + description: 'Items per page (default: 25, max: 100)' + parameter name: :resource_type, in: :query, required: false, + description: 'Filter by rule resource type', + schema: { type: :string, enum: %w[transaction] } + parameter name: :active, in: :query, required: false, + description: 'Filter by active status', + schema: { type: :boolean } + + response '200', 'rules listed' do + schema '$ref' => '#/components/schemas/RuleCollection' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'forbidden - requires read scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '422', 'invalid active filter' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:active) { 'not_boolean' } + + run_test! + end + + response '422', 'unsupported resource type' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:resource_type) { 'account' } + + run_test! + end + end + end + + path '/api/v1/rules/{id}' do + parameter name: :id, in: :path, type: :string, required: true, description: 'Rule ID' + + get 'Retrieve a rule' do + tags 'Rules' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + let(:id) { rule.id } + + response '200', 'rule retrieved' do + schema '$ref' => '#/components/schemas/RuleResponse' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'forbidden - requires read scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '404', 'rule not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 123603dbe..9ad0e61cc 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -305,6 +305,83 @@ RSpec.configure do |config| type: :array, items: { '$ref' => '#/components/schemas/TagDetail' } }, + RuleAction: { + type: :object, + required: %w[id action_type created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + action_type: { type: :string }, + value: { type: :string, nullable: true }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + RuleCondition: { + type: :object, + required: %w[id condition_type operator sub_conditions created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + condition_type: { type: :string }, + operator: { type: :string }, + value: { type: :string, nullable: true }, + sub_conditions: { + type: :array, + items: { '$ref' => '#/components/schemas/RuleCondition' } + }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + Rule: { + type: :object, + required: %w[id resource_type active conditions actions created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string, nullable: true }, + resource_type: { type: :string, enum: %w[transaction] }, + active: { type: :boolean }, + effective_date: { type: :string, format: :date, nullable: true }, + conditions: { + type: :array, + items: { '$ref' => '#/components/schemas/RuleCondition' } + }, + actions: { + type: :array, + items: { '$ref' => '#/components/schemas/RuleAction' } + }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + RuleResponse: { + type: :object, + required: %w[data], + properties: { + data: { '$ref' => '#/components/schemas/Rule' } + } + }, + RuleCollection: { + type: :object, + required: %w[data meta], + properties: { + data: { + type: :array, + items: { '$ref' => '#/components/schemas/Rule' } + }, + meta: { + type: :object, + required: %w[current_page total_pages total_count per_page], + properties: { + current_page: { type: :integer }, + next_page: { type: :integer, nullable: true }, + prev_page: { type: :integer, nullable: true }, + total_pages: { type: :integer }, + total_count: { type: :integer }, + per_page: { type: :integer } + } + } + } + }, Transfer: { type: :object, required: %w[id amount currency], diff --git a/test/controllers/api/v1/rules_controller_test.rb b/test/controllers/api/v1/rules_controller_test.rb new file mode 100644 index 000000000..5360aa73e --- /dev/null +++ b/test/controllers/api/v1/rules_controller_test.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::RulesControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @family = @user.family + + @user.api_keys.active.destroy_all + @api_key = ApiKey.create!( + user: @user, + name: "Test Read Key", + scopes: [ "read" ], + source: "web", + display_key: "test_read_#{SecureRandom.hex(8)}" + ) + + Redis.new.del("api_rate_limit:#{@api_key.id}") + + @rule = @family.rules.build( + name: "Coffee cleanup", + resource_type: "transaction", + active: true, + effective_date: Date.new(2024, 1, 1) + ) + @rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "coffee") + @rule.actions.build(action_type: "set_transaction_name", value: "Coffee") + @rule.save! + end + + test "should list rules" do + get api_v1_rules_url, headers: api_headers(@api_key) + assert_response :success + + json_response = JSON.parse(response.body) + assert json_response["data"].any? { |rule| rule["id"] == @rule.id } + assert_equal @family.rules.count, json_response["meta"]["total_count"] + end + + test "should not list another family's rules" do + other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en") + other_rule = other_family.rules.build(name: "Other", resource_type: "transaction", active: true) + other_rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "other") + other_rule.actions.build(action_type: "set_transaction_name", value: "Other") + other_rule.save! + + get api_v1_rules_url, headers: api_headers(@api_key) + assert_response :success + + rule_ids = JSON.parse(response.body)["data"].map { |rule| rule["id"] } + assert_includes rule_ids, @rule.id + assert_not_includes rule_ids, other_rule.id + end + + test "should require authentication when listing rules" do + get api_v1_rules_url + + assert_response :unauthorized + end + + test "should require read scope when listing rules" do + api_key_without_read = api_key_without_read_scope + + get api_v1_rules_url, headers: api_headers(api_key_without_read) + + assert_response :forbidden + json_response = JSON.parse(response.body) + assert_equal "insufficient_scope", json_response["error"] + ensure + api_key_without_read&.destroy + end + + test "should filter rules by active status" do + inactive_rule = @family.rules.build(name: "Inactive", resource_type: "transaction", active: false) + inactive_rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "ignore") + inactive_rule.actions.build(action_type: "set_transaction_name", value: "Ignore") + inactive_rule.save! + + get api_v1_rules_url, params: { active: true }, headers: api_headers(@api_key) + assert_response :success + + json_response = JSON.parse(response.body) + rule_ids = json_response["data"].map { |rule| rule["id"] } + assert_includes rule_ids, @rule.id + assert_not_includes rule_ids, inactive_rule.id + end + + test "should reject invalid active filter" do + get api_v1_rules_url, params: { active: "not_boolean" }, headers: api_headers(@api_key) + + assert_response :unprocessable_entity + json_response = JSON.parse(response.body) + assert_equal "validation_failed", json_response["error"] + end + + test "should reject unsupported resource type filter" do + get api_v1_rules_url, params: { resource_type: "account" }, headers: api_headers(@api_key) + + assert_response :unprocessable_entity + json_response = JSON.parse(response.body) + assert_equal "validation_failed", json_response["error"] + end + + test "should show rule with conditions and actions" do + get api_v1_rule_url(@rule), headers: api_headers(@api_key) + assert_response :success + + rule = JSON.parse(response.body)["data"] + assert_equal @rule.id, rule["id"] + assert_equal "Coffee cleanup", rule["name"] + assert_equal "transaction", rule["resource_type"] + assert_equal true, rule["active"] + assert_equal "2024-01-01", rule["effective_date"] + + assert_equal 1, rule["conditions"].length + assert_equal "transaction_name", rule["conditions"].first["condition_type"] + assert_equal "like", rule["conditions"].first["operator"] + assert_equal "coffee", rule["conditions"].first["value"] + + assert_equal 1, rule["actions"].length + assert_equal "set_transaction_name", rule["actions"].first["action_type"] + assert_equal "Coffee", rule["actions"].first["value"] + end + + test "should require authentication when showing a rule" do + get api_v1_rule_url(@rule) + + assert_response :unauthorized + end + + test "should require read scope when showing a rule" do + api_key_without_read = api_key_without_read_scope + + get api_v1_rule_url(@rule), headers: api_headers(api_key_without_read) + + assert_response :forbidden + json_response = JSON.parse(response.body) + assert_equal "insufficient_scope", json_response["error"] + ensure + api_key_without_read&.destroy + end + + test "should not show another family's rule" do + other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en") + other_rule = other_family.rules.build(name: "Other", resource_type: "transaction", active: true) + other_rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "other") + other_rule.actions.build(action_type: "set_transaction_name", value: "Other") + other_rule.save! + + get api_v1_rule_url(other_rule), headers: api_headers(@api_key) + assert_response :not_found + json_response = JSON.parse(response.body) + assert_equal "record_not_found", json_response["error"] + end + + private + + def api_key_without_read_scope + # Valid persisted API keys can only be read/read_write; this intentionally + # bypasses validations to exercise the runtime insufficient-scope guard. + ApiKey.new( + user: @user, + name: "No Read Key", + scopes: [], + display_key: "test_no_read_#{SecureRandom.hex(8)}", + source: "mobile" + ).tap { |api_key| api_key.save!(validate: false) } + end + + def api_headers(api_key) + { "X-Api-Key" => api_key.plain_key } + end +end