mirror of
https://github.com/we-promise/sure.git
synced 2026-05-12 07:05:00 +00:00
feat(api): expose rule export endpoints (#1602)
* feat(api): expose rule export endpoints * fix(api): tighten rule export contracts * fix(api): document balance sheet auth errors * test(api): align rule API key fixtures * Update docs/api/openapi.yaml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Juan José Mata <jjmata@jjmata.com> * Quick win Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Juan José Mata <jjmata@jjmata.com> --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Signed-off-by: Juan José Mata <jjmata@jjmata.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <jjmata@jjmata.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
94
app/controllers/api/v1/rules_controller.rb
Normal file
94
app/controllers/api/v1/rules_controller.rb
Normal file
@@ -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
|
||||
7
app/views/api/v1/rules/_action.json.jbuilder
Normal file
7
app/views/api/v1/rules/_action.json.jbuilder
Normal file
@@ -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
|
||||
17
app/views/api/v1/rules/_condition.json.jbuilder
Normal file
17
app/views/api/v1/rules/_condition.json.jbuilder
Normal file
@@ -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
|
||||
15
app/views/api/v1/rules/_rule.json.jbuilder
Normal file
15
app/views/api/v1/rules/_rule.json.jbuilder
Normal file
@@ -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
|
||||
14
app/views/api/v1/rules/index.json.jbuilder
Normal file
14
app/views/api/v1/rules/index.json.jbuilder
Normal file
@@ -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
|
||||
5
app/views/api/v1/rules/show.json.jbuilder
Normal file
5
app/views/api/v1/rules/show.json.jbuilder
Normal file
@@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.data do
|
||||
json.partial! "api/v1/rules/rule", rule: @rule
|
||||
end
|
||||
@@ -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 ]
|
||||
|
||||
@@ -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
|
||||
|
||||
160
spec/requests/api/v1/rules_spec.rb
Normal file
160
spec/requests/api/v1/rules_spec.rb
Normal file
@@ -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
|
||||
@@ -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],
|
||||
|
||||
174
test/controllers/api/v1/rules_controller_test.rb
Normal file
174
test/controllers/api/v1/rules_controller_test.rb
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user