mirror of
https://github.com/we-promise/sure.git
synced 2026-05-07 21:04:12 +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:
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