Files
discourse/spec/models/api_key_spec.rb
Ted Johansson 06a0108a52 DEV: Store selected API key scope mode in the database table (#31601)
Currently, after creating an API key, there is no way in the UI to see what scope the key has. To do this we need to first store the selected scope mode when creating a new key.

In this PR we:

- Convert scope_mode from a transient attribute to a database backed enum.
- Ship the possible values through the javascript:update_constants rake task instead of hard coding in front-end.

In follow-up PRs we will:

- Backfill existing API keys based on their associated api_key_scopes records.
- Start showing the scope mode in the UI.
2025-03-04 16:41:43 +08:00

193 lines
6.3 KiB
Ruby

# encoding: utf-8
# frozen_string_literal: true
RSpec.describe ApiKey do
fab!(:user)
it { is_expected.to belong_to :user }
it { is_expected.to belong_to :created_by }
it { is_expected.to validate_length_of(:description).is_at_most(255) }
it { is_expected.to define_enum_for(:scope_mode).with_values(%w[global read_only granular]) }
it "validates at least one scope for granular mode" do
api_key = ApiKey.new
api_key.scope_mode = "granular"
api_key.validate
expect(api_key.errors).to contain_exactly("Api key scopes at least one must be selected")
end
it "generates a key when saving" do
api_key = ApiKey.new
api_key.save!
initial_key = api_key.key
expect(initial_key.length).to eq(64)
# Does not overwrite key when saving again
api_key.description = "My description here"
api_key.save!
expect(api_key.reload.key).to eq(initial_key)
end
it "does not have the key when loading later from the database" do
api_key = ApiKey.create!
expect(api_key.key_available?).to eq(true)
expect(api_key.key.length).to eq(64)
api_key = ApiKey.find(api_key.id)
expect(api_key.key_available?).to eq(false)
expect { api_key.key }.to raise_error(ApiKey::KeyAccessError)
end
it "can lookup keys based on their hash" do
key = ApiKey.create!.key
expect(ApiKey.with_key(key).length).to eq(1)
end
it "can calculate the epoch correctly" do
expect(ApiKey.last_used_epoch.to_datetime).to be_a(DateTime)
SiteSetting.api_key_last_used_epoch = ""
expect(ApiKey.last_used_epoch).to eq(nil)
end
it "can automatically revoke unused keys" do
now = Time.now
SiteSetting.api_key_last_used_epoch = now - 2.years
SiteSetting.revoke_api_keys_unused_days = 180 # 6 months
freeze_time now - 1.year
never_used = Fabricate(:api_key)
used_previously = Fabricate(:api_key)
used_previously.update(last_used_at: Time.zone.now)
used_recently = Fabricate(:api_key)
freeze_time now - 3.months
used_recently.update(last_used_at: Time.zone.now)
freeze_time now
ApiKey.revoke_unused_keys!
[never_used, used_previously, used_recently].each(&:reload)
expect(never_used.revoked_at).to_not eq(nil)
expect(used_previously.revoked_at).to_not eq(nil)
expect(used_recently.revoked_at).to eq(nil)
# Restore them
[never_used, used_previously, used_recently].each { |a| a.update(revoked_at: nil) }
# Move the epoch to 1 month ago
SiteSetting.api_key_last_used_epoch = now - 1.month
ApiKey.revoke_unused_keys!
[never_used, used_previously, used_recently].each(&:reload)
expect(never_used.revoked_at).to eq(nil)
expect(used_previously.revoked_at).to eq(nil)
expect(used_recently.revoked_at).to eq(nil)
end
it "can automatically revoke keys by max life" do
freeze_time
SiteSetting.revoke_api_keys_maxlife_days = 2
older_key = Fabricate(:api_key, created_at: 3.days.ago)
newer_key = Fabricate(:api_key, created_at: 1.days.ago)
revoked_key = Fabricate(:api_key, created_at: 3.days.ago, revoked_at: 1.day.ago)
expect { ApiKey.revoke_max_life_keys! }.to change { older_key.reload.revoked_at }.from(nil).to(
be_within_one_second_of Time.current
).and not_change { newer_key.reload.revoked_at }.and not_change {
revoked_key.reload.revoked_at
}
end
describe "API Key scope mappings" do
it "maps api_key permissions" do
api_key_mappings = ApiKeyScope.scope_mappings[:topics]
assert_responds_to(api_key_mappings.dig(:write, :actions))
assert_responds_to(api_key_mappings.dig(:read, :actions))
assert_responds_to(api_key_mappings.dig(:read_lists, :actions))
end
def assert_responds_to(mappings)
mappings.each do |m|
controller, method = m.split("#")
controller_name = "#{controller.capitalize}Controller"
expect(controller_name.constantize.method_defined?(method)).to eq(true)
end
end
end
describe "#request_allowed?" do
let(:request) do
ActionDispatch::TestRequest.create.tap do |request|
request.path_parameters = { controller: "topics", action: "show", topic_id: "3" }
request.remote_addr = "133.45.67.99"
end
end
let(:env) { request.env }
let(:key) { ApiKey.new(api_key_scopes: [scope]) }
context "with regular scopes" do
let(:scope) do
ApiKeyScope.new(resource: "topics", action: "read", allowed_parameters: { topic_id: "3" })
end
it "allows the request if there are no allowed IPs" do
key.allowed_ips = nil
key.api_key_scopes = []
expect(key.request_allowed?(env)).to eq(true)
end
it "rejects the request if the IP is not allowed" do
key.allowed_ips = %w[115.65.76.87]
expect(key.request_allowed?(env)).to eq(false)
end
it "allow the request if there are not allowed params" do
scope.allowed_parameters = nil
expect(key.request_allowed?(env)).to eq(true)
end
it "rejects the request when params are different" do
request.path_parameters = { controller: "topics", action: "show", topic_id: "4" }
expect(key.request_allowed?(env)).to eq(false)
end
it "accepts the request if one of the parameters match" do
request.path_parameters = { controller: "topics", action: "show", topic_id: "4" }
scope.allowed_parameters = { topic_id: %w[3 4] }
expect(key.request_allowed?(env)).to eq(true)
end
it "allow the request when the scope has an alias" do
request.path_parameters = { controller: "topics", action: "show", id: "3" }
expect(key.request_allowed?(env)).to eq(true)
end
it "rejects the request when the main parameter and the alias are both used" do
request.path_parameters = { controller: "topics", action: "show", topic_id: "3", id: "3" }
expect(key.request_allowed?(env)).to eq(false)
end
end
context "with global:read scope" do
let(:scope) { ApiKeyScope.new(resource: "global", action: "read") }
it "allows only GET requests for global:read" do
request.request_method = "GET"
expect(key.request_allowed?(env)).to eq(true)
request.request_method = "POST"
expect(key.request_allowed?(env)).to eq(false)
end
end
end
end