mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 21:29:38 +02:00
442 lines
14 KiB
Ruby
442 lines
14 KiB
Ruby
|
# frozen_string_literal: true
|
||
|
|
||
|
require "test_helper"
|
||
|
|
||
|
class Api::V1::BaseControllerTest < ActionDispatch::IntegrationTest
|
||
|
setup do
|
||
|
@user = users(:family_admin)
|
||
|
@oauth_app = Doorkeeper::Application.create!(
|
||
|
name: "Test API App",
|
||
|
redirect_uri: "https://example.com/callback",
|
||
|
scopes: "read read_write"
|
||
|
)
|
||
|
|
||
|
# Clean up any existing API keys for the test user
|
||
|
@user.api_keys.destroy_all
|
||
|
|
||
|
# Create a test API key
|
||
|
@plain_api_key = "base_test_#{SecureRandom.hex(8)}"
|
||
|
@api_key = ApiKey.create!(
|
||
|
user: @user,
|
||
|
name: "Test API Key",
|
||
|
display_key: @plain_api_key,
|
||
|
scopes: [ "read_write" ]
|
||
|
)
|
||
|
|
||
|
# Clear any existing rate limit data
|
||
|
Redis.new.del("api_rate_limit:#{@api_key.id}")
|
||
|
end
|
||
|
|
||
|
teardown do
|
||
|
# Clean up Redis data after each test
|
||
|
Redis.new.del("api_rate_limit:#{@api_key.id}")
|
||
|
end
|
||
|
|
||
|
test "should require authentication" do
|
||
|
# Test that endpoints require OAuth tokens
|
||
|
get "/api/v1/test"
|
||
|
assert_response :unauthorized
|
||
|
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "unauthorized", response_body["error"]
|
||
|
end
|
||
|
|
||
|
test "should authenticate with valid access token" do
|
||
|
# Create a valid access token
|
||
|
access_token = Doorkeeper::AccessToken.create!(
|
||
|
application: @oauth_app,
|
||
|
resource_owner_id: @user.id,
|
||
|
scopes: "read"
|
||
|
)
|
||
|
|
||
|
get "/api/v1/test", params: {}, headers: {
|
||
|
"Authorization" => "Bearer #{access_token.token}"
|
||
|
}
|
||
|
|
||
|
# Should not be unauthorized when token is valid
|
||
|
assert_response :success
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "test_success", response_body["message"]
|
||
|
assert_equal @user.email, response_body["user"]
|
||
|
end
|
||
|
|
||
|
test "should reject invalid access token" do
|
||
|
get "/api/v1/test", params: {}, headers: {
|
||
|
"Authorization" => "Bearer invalid_token"
|
||
|
}
|
||
|
|
||
|
assert_response :unauthorized
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "unauthorized", response_body["error"]
|
||
|
end
|
||
|
|
||
|
test "should authenticate with valid API key" do
|
||
|
get "/api/v1/test", params: {}, headers: {
|
||
|
"X-Api-Key" => @plain_api_key
|
||
|
}
|
||
|
|
||
|
assert_response :success
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "test_success", response_body["message"]
|
||
|
assert_equal @user.email, response_body["user"]
|
||
|
end
|
||
|
|
||
|
test "should reject invalid API key" do
|
||
|
get "/api/v1/test", params: {}, headers: {
|
||
|
"X-Api-Key" => "invalid_api_key"
|
||
|
}
|
||
|
|
||
|
assert_response :unauthorized
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "unauthorized", response_body["error"]
|
||
|
assert_includes response_body["message"], "Access token or API key"
|
||
|
end
|
||
|
|
||
|
test "should reject expired API key" do
|
||
|
@api_key.update!(expires_at: 1.day.ago)
|
||
|
|
||
|
get "/api/v1/test", params: {}, headers: {
|
||
|
"X-Api-Key" => @plain_api_key
|
||
|
}
|
||
|
|
||
|
assert_response :unauthorized
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "unauthorized", response_body["error"]
|
||
|
end
|
||
|
|
||
|
test "should reject revoked API key" do
|
||
|
@api_key.revoke!
|
||
|
|
||
|
get "/api/v1/test", params: {}, headers: {
|
||
|
"X-Api-Key" => @plain_api_key
|
||
|
}
|
||
|
|
||
|
assert_response :unauthorized
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "unauthorized", response_body["error"]
|
||
|
end
|
||
|
|
||
|
test "should update last_used_at when API key is used" do
|
||
|
original_time = @api_key.last_used_at
|
||
|
|
||
|
get "/api/v1/test", params: {}, headers: {
|
||
|
"X-Api-Key" => @plain_api_key
|
||
|
}
|
||
|
|
||
|
assert_response :success
|
||
|
@api_key.reload
|
||
|
assert_not_equal original_time, @api_key.last_used_at
|
||
|
assert @api_key.last_used_at > (original_time || Time.at(0))
|
||
|
end
|
||
|
|
||
|
test "should prioritize OAuth over API key when both are provided" do
|
||
|
access_token = Doorkeeper::AccessToken.create!(
|
||
|
application: @oauth_app,
|
||
|
resource_owner_id: @user.id,
|
||
|
scopes: "read"
|
||
|
)
|
||
|
|
||
|
# Capture log output to verify OAuth is used
|
||
|
logs = capture_log do
|
||
|
get "/api/v1/test", params: {}, headers: {
|
||
|
"Authorization" => "Bearer #{access_token.token}",
|
||
|
"X-Api-Key" => @plain_api_key
|
||
|
}
|
||
|
end
|
||
|
|
||
|
assert_response :success
|
||
|
assert_includes logs, "OAuth Token"
|
||
|
assert_not_includes logs, "API Key:"
|
||
|
end
|
||
|
|
||
|
test "should provide current_scopes for API key authentication" do
|
||
|
get "/api/v1/test_scope_required", params: {}, headers: {
|
||
|
"X-Api-Key" => @plain_api_key
|
||
|
}
|
||
|
|
||
|
assert_response :success
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "scope_authorized", response_body["message"]
|
||
|
assert_includes response_body["scopes"], "read_write"
|
||
|
end
|
||
|
|
||
|
test "should authorize API key with required scope" do
|
||
|
get "/api/v1/test_scope_required", params: {}, headers: {
|
||
|
"X-Api-Key" => @plain_api_key
|
||
|
}
|
||
|
|
||
|
assert_response :success
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "scope_authorized", response_body["message"]
|
||
|
assert_equal "write", response_body["required_scope"]
|
||
|
end
|
||
|
|
||
|
test "should reject API key without required scope" do
|
||
|
# Revoke existing API key and create one with limited scopes
|
||
|
@api_key.revoke!
|
||
|
limited_api_key = ApiKey.create!(
|
||
|
user: @user,
|
||
|
name: "Limited API Key",
|
||
|
display_key: "limited_key_#{SecureRandom.hex(8)}",
|
||
|
scopes: [ "read" ] # Only read scope
|
||
|
)
|
||
|
|
||
|
get "/api/v1/test_scope_required", params: {}, headers: {
|
||
|
"X-Api-Key" => limited_api_key.display_key
|
||
|
}
|
||
|
|
||
|
assert_response :forbidden
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "insufficient_scope", response_body["error"]
|
||
|
assert_includes response_body["message"], "write"
|
||
|
end
|
||
|
|
||
|
test "should authorize API key with multiple required scopes" do
|
||
|
get "/api/v1/test_multiple_scopes_required", params: {}, headers: {
|
||
|
"X-Api-Key" => @plain_api_key
|
||
|
}
|
||
|
|
||
|
assert_response :success
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "read_scope_authorized", response_body["message"]
|
||
|
assert_includes response_body["scopes"], "read_write"
|
||
|
end
|
||
|
|
||
|
test "should reject API key missing one of multiple required scopes" do
|
||
|
# The multiple scopes test now just checks for "read" permission,
|
||
|
# so we need to create an API key without any scopes at all.
|
||
|
# First revoke the existing key, then create one with empty scopes array won't work due to validation.
|
||
|
# Instead, we'll test by trying to access the write endpoint with a read-only key.
|
||
|
@api_key.revoke!
|
||
|
|
||
|
read_only_key = ApiKey.create!(
|
||
|
user: @user,
|
||
|
name: "Read Only API Key",
|
||
|
display_key: "read_only_key_#{SecureRandom.hex(8)}",
|
||
|
scopes: [ "read" ] # Only read scope, no write
|
||
|
)
|
||
|
|
||
|
# Try to access the write-requiring endpoint with read-only key
|
||
|
get "/api/v1/test_scope_required", params: {}, headers: {
|
||
|
"X-Api-Key" => read_only_key.display_key
|
||
|
}
|
||
|
|
||
|
assert_response :forbidden
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "insufficient_scope", response_body["error"]
|
||
|
end
|
||
|
|
||
|
test "should log API access with API key information" do
|
||
|
logs = capture_log do
|
||
|
get "/api/v1/test", params: {}, headers: {
|
||
|
"X-Api-Key" => @plain_api_key
|
||
|
}
|
||
|
end
|
||
|
|
||
|
assert_includes logs, "API Request"
|
||
|
assert_includes logs, "GET /api/v1/test"
|
||
|
assert_includes logs, @user.email
|
||
|
end
|
||
|
|
||
|
test "should provide current_resource_owner method" do
|
||
|
# This will be tested through the test controller once implemented
|
||
|
skip "Will test via test controller implementation"
|
||
|
end
|
||
|
|
||
|
test "should handle ActiveRecord::RecordNotFound errors" do
|
||
|
access_token = Doorkeeper::AccessToken.create!(
|
||
|
application: @oauth_app,
|
||
|
resource_owner_id: @user.id,
|
||
|
scopes: "read"
|
||
|
)
|
||
|
|
||
|
# This will trigger a not found error in the test controller
|
||
|
get "/api/v1/test_not_found", params: {}, headers: {
|
||
|
"Authorization" => "Bearer #{access_token.token}"
|
||
|
}
|
||
|
|
||
|
assert_response :not_found
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "record_not_found", response_body["error"]
|
||
|
end
|
||
|
|
||
|
test "should log API access" do
|
||
|
access_token = Doorkeeper::AccessToken.create!(
|
||
|
application: @oauth_app,
|
||
|
resource_owner_id: @user.id,
|
||
|
scopes: "read"
|
||
|
)
|
||
|
|
||
|
# Capture log output
|
||
|
logs = capture_log do
|
||
|
get "/api/v1/test", params: {}, headers: {
|
||
|
"Authorization" => "Bearer #{access_token.token}"
|
||
|
}
|
||
|
end
|
||
|
|
||
|
assert_includes logs, "API Request"
|
||
|
assert_includes logs, "GET /api/v1/test"
|
||
|
assert_includes logs, @user.email
|
||
|
end
|
||
|
|
||
|
test "should enforce family-based access control" do
|
||
|
# Create another family user
|
||
|
other_family = families(:dylan_family)
|
||
|
other_user = users(:family_member)
|
||
|
other_user.update!(family: other_family)
|
||
|
|
||
|
access_token = Doorkeeper::AccessToken.create!(
|
||
|
application: @oauth_app,
|
||
|
resource_owner_id: other_user.id,
|
||
|
scopes: "read"
|
||
|
)
|
||
|
|
||
|
# Try to access data from a different family
|
||
|
get "/api/v1/test_family_access", params: {}, headers: {
|
||
|
"Authorization" => "Bearer #{access_token.token}"
|
||
|
}
|
||
|
|
||
|
assert_response :forbidden
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "forbidden", response_body["error"]
|
||
|
end
|
||
|
|
||
|
test "should enforce family-based access control with API key" do
|
||
|
# Create API key for a user in a different family
|
||
|
other_family = families(:dylan_family)
|
||
|
other_user = users(:family_member)
|
||
|
other_user.update!(family: other_family)
|
||
|
other_user.api_keys.destroy_all
|
||
|
|
||
|
other_user_api_key = ApiKey.create!(
|
||
|
user: other_user,
|
||
|
name: "Other User API Key",
|
||
|
display_key: "other_user_key_#{SecureRandom.hex(8)}",
|
||
|
scopes: [ "read" ]
|
||
|
)
|
||
|
|
||
|
# Try to access data from a different family
|
||
|
get "/api/v1/test_family_access", params: {}, headers: {
|
||
|
"X-Api-Key" => other_user_api_key.display_key
|
||
|
}
|
||
|
|
||
|
assert_response :forbidden
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "forbidden", response_body["error"]
|
||
|
end
|
||
|
|
||
|
test "should include rate limit headers on successful API key requests" do
|
||
|
get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key }
|
||
|
|
||
|
assert_response :success
|
||
|
assert_not_nil response.headers["X-RateLimit-Limit"]
|
||
|
assert_not_nil response.headers["X-RateLimit-Remaining"]
|
||
|
assert_not_nil response.headers["X-RateLimit-Reset"]
|
||
|
|
||
|
assert_equal "100", response.headers["X-RateLimit-Limit"]
|
||
|
assert_equal "99", response.headers["X-RateLimit-Remaining"]
|
||
|
end
|
||
|
|
||
|
test "should increment rate limit count with each request" do
|
||
|
# First request
|
||
|
get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key }
|
||
|
assert_response :success
|
||
|
assert_equal "99", response.headers["X-RateLimit-Remaining"]
|
||
|
|
||
|
# Second request
|
||
|
get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key }
|
||
|
assert_response :success
|
||
|
assert_equal "98", response.headers["X-RateLimit-Remaining"]
|
||
|
end
|
||
|
|
||
|
test "should return 429 when rate limit exceeded" do
|
||
|
# Make 100 requests to exhaust the rate limit
|
||
|
100.times do
|
||
|
get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key }
|
||
|
assert_response :success
|
||
|
end
|
||
|
|
||
|
# 101st request should be rate limited
|
||
|
get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key }
|
||
|
assert_response :too_many_requests
|
||
|
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "rate_limit_exceeded", response_body["error"]
|
||
|
assert_includes response_body["message"], "Rate limit exceeded"
|
||
|
|
||
|
# Check response headers
|
||
|
assert_equal "100", response.headers["X-RateLimit-Limit"]
|
||
|
assert_equal "0", response.headers["X-RateLimit-Remaining"]
|
||
|
assert_not_nil response.headers["X-RateLimit-Reset"]
|
||
|
assert_not_nil response.headers["Retry-After"]
|
||
|
end
|
||
|
|
||
|
test "should not apply rate limiting to OAuth requests" do
|
||
|
# This would need to be implemented based on your OAuth setup
|
||
|
# For now, just verify that requests without API keys don't trigger rate limiting
|
||
|
get "/api/v1/test"
|
||
|
assert_response :unauthorized
|
||
|
|
||
|
# Should not have rate limit headers for unauthorized requests
|
||
|
assert_nil response.headers["X-RateLimit-Limit"]
|
||
|
end
|
||
|
|
||
|
test "should provide detailed rate limit information in 429 response" do
|
||
|
# Exhaust the rate limit
|
||
|
100.times do
|
||
|
get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key }
|
||
|
end
|
||
|
|
||
|
# Make the rate-limited request
|
||
|
get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key }
|
||
|
assert_response :too_many_requests
|
||
|
|
||
|
response_body = JSON.parse(response.body)
|
||
|
assert_equal "rate_limit_exceeded", response_body["error"]
|
||
|
assert response_body["details"]["limit"] == 100
|
||
|
assert response_body["details"]["current"] >= 100
|
||
|
assert response_body["details"]["reset_in_seconds"] > 0
|
||
|
end
|
||
|
|
||
|
test "rate limiting should be per API key" do
|
||
|
# Create a second user for independent API keys
|
||
|
other_user = users(:family_member)
|
||
|
other_api_key = ApiKey.create!(
|
||
|
user: other_user,
|
||
|
name: "Other Test API Key",
|
||
|
scopes: [ "read" ],
|
||
|
display_key: "other_rate_test_#{SecureRandom.hex(8)}"
|
||
|
)
|
||
|
|
||
|
begin
|
||
|
# Make 50 requests with first API key
|
||
|
50.times do
|
||
|
get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key }
|
||
|
assert_response :success
|
||
|
end
|
||
|
|
||
|
# Should still be able to make requests with second API key
|
||
|
get "/api/v1/test", headers: { "X-Api-Key" => other_api_key.display_key }
|
||
|
assert_response :success
|
||
|
assert_equal "99", response.headers["X-RateLimit-Remaining"]
|
||
|
ensure
|
||
|
Redis.new.del("api_rate_limit:#{other_api_key.id}")
|
||
|
other_api_key.destroy
|
||
|
end
|
||
|
end
|
||
|
|
||
|
private
|
||
|
|
||
|
def capture_log(&block)
|
||
|
io = StringIO.new
|
||
|
original_logger = Rails.logger
|
||
|
Rails.logger = Logger.new(io)
|
||
|
|
||
|
yield
|
||
|
|
||
|
io.string
|
||
|
ensure
|
||
|
Rails.logger = original_logger
|
||
|
end
|
||
|
end
|