Commit d25fd584 authored by Alex Kalderimis's avatar Alex Kalderimis Committed by Nick Thomas

Add specs for simple lazy promise

This promise abstracts over a few different kinds of laziness we have,
without coupling super tightly to the GitlabSchema.
parent efceaf81
...@@ -101,3 +101,5 @@ apollo.config.js ...@@ -101,3 +101,5 @@ apollo.config.js
/tmp/matching_tests.txt /tmp/matching_tests.txt
ee/changelogs/unreleased-ee ee/changelogs/unreleased-ee
/sitespeed-result /sitespeed-result
tags.lock
tags.temp
...@@ -30,6 +30,8 @@ class GitlabSchema < GraphQL::Schema ...@@ -30,6 +30,8 @@ class GitlabSchema < GraphQL::Schema
default_max_page_size 100 default_max_page_size 100
lazy_resolve ::Gitlab::Graphql::Lazy, :force
class << self class << self
def multiplex(queries, **kwargs) def multiplex(queries, **kwargs)
kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context]) kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context])
......
...@@ -104,7 +104,7 @@ class FeatureFlagOptionParser ...@@ -104,7 +104,7 @@ class FeatureFlagOptionParser
end end
# Name is a first name # Name is a first name
options.name = argv.first options.name = argv.first.downcase.gsub(/-/, '_')
options options
end end
......
---
name: graphql_lazy_authorization
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45263
rollout_issue_url:
type: development
group: group::plan
default_enabled: false
...@@ -46,6 +46,8 @@ module Gitlab ...@@ -46,6 +46,8 @@ module Gitlab
# Returns any authorize metadata from @field # Returns any authorize metadata from @field
def field_authorizations def field_authorizations
return [] if @field.metadata[:authorize] == true
Array.wrap(@field.metadata[:authorize]) Array.wrap(@field.metadata[:authorize])
end end
...@@ -54,7 +56,7 @@ module Gitlab ...@@ -54,7 +56,7 @@ module Gitlab
# The field is a built-in/scalar type, or a list of scalars # The field is a built-in/scalar type, or a list of scalars
# authorize using the parent's object # authorize using the parent's object
parent_typed_object.object parent_typed_object.object
elsif @field.connection? || resolved_type.is_a?(Array) elsif @field.connection? || @field.type.list? || resolved_type.is_a?(Array)
# The field is a connection or a list of non-built-in types, we'll # The field is a connection or a list of non-built-in types, we'll
# authorize each element when rendering # authorize each element when rendering
nil nil
...@@ -75,16 +77,25 @@ module Gitlab ...@@ -75,16 +77,25 @@ module Gitlab
# no need to do anything # no need to do anything
elsif authorizing_object elsif authorizing_object
# Authorizing fields representing scalars, or a simple field with an object # Authorizing fields representing scalars, or a simple field with an object
resolved_type if allowed_access?(current_user, authorizing_object) ::Gitlab::Graphql::Lazy.with_value(authorizing_object) do |object|
resolved_type if allowed_access?(current_user, object)
end
elsif @field.connection? elsif @field.connection?
::Gitlab::Graphql::Lazy.with_value(resolved_type) do |type|
# A connection with pagination, modify the visible nodes on the # A connection with pagination, modify the visible nodes on the
# connection type in place # connection type in place
resolved_type.object.edge_nodes.to_a.keep_if { |node| allowed_access?(current_user, node) } nodes = to_nodes(type)
resolved_type nodes.keep_if { |node| allowed_access?(current_user, node) } if nodes
elsif resolved_type.is_a? Array type
end
elsif @field.type.list? || resolved_type.is_a?(Array)
# A simple list of rendered types each object being an object to authorize # A simple list of rendered types each object being an object to authorize
resolved_type.select do |single_object_type| ::Gitlab::Graphql::Lazy.with_value(resolved_type) do |items|
allowed_access?(current_user, realized(single_object_type).object) items.select do |single_object_type|
object_type = realized(single_object_type)
object = object_type.try(:object) || object_type
allowed_access?(current_user, object)
end
end end
else else
raise "Can't authorize #{@field}" raise "Can't authorize #{@field}"
...@@ -93,18 +104,23 @@ module Gitlab ...@@ -93,18 +104,23 @@ module Gitlab
# Ensure that we are dealing with realized objects, not delayed promises # Ensure that we are dealing with realized objects, not delayed promises
def realized(thing) def realized(thing)
case thing ::Gitlab::Graphql::Lazy.force(thing)
when BatchLoader::GraphQL end
thing.sync
when GraphQL::Execution::Lazy # Try to get the connection
thing.value # part of the private api, but we need to unwrap it here. # can be at type.object or at type
def to_nodes(type)
if type.respond_to?(:nodes)
type.nodes
elsif type.respond_to?(:object)
to_nodes(type.object)
else else
thing nil
end end
end end
def allowed_access?(current_user, object) def allowed_access?(current_user, object)
object = object.sync if object.respond_to?(:sync) object = realized(object)
authorizations.all? do |ability| authorizations.all? do |ability|
Ability.allowed?(current_user, ability, object) Ability.allowed?(current_user, ability, object)
......
...@@ -3,17 +3,45 @@ ...@@ -3,17 +3,45 @@
module Gitlab module Gitlab
module Graphql module Graphql
class Lazy class Lazy
include Gitlab::Utils::StrongMemoize
def initialize(&block)
@proc = block
end
def force
strong_memoize(:force) { self.class.force(@proc.call) }
end
def then(&block)
self.class.new { yield force }
end
# Force evaluation of a (possibly) lazy value # Force evaluation of a (possibly) lazy value
def self.force(value) def self.force(value)
case value case value
when ::Gitlab::Graphql::Lazy
value.force
when ::BatchLoader::GraphQL when ::BatchLoader::GraphQL
value.sync value.sync
when ::GraphQL::Execution::Lazy
value.value # part of the private api, but we can force this as well
when ::Concurrent::Promise when ::Concurrent::Promise
value.execute.value value.execute if value.state == :unscheduled
value.value # value.value(10.seconds)
else else
value value
end end
end end
def self.with_value(unforced, &block)
if Feature.enabled?(:graphql_lazy_authorization)
self.new { unforced }.then(&block)
else
block.call(unforced)
end
end
end end
end end
end end
...@@ -13,7 +13,7 @@ RSpec.describe 'bin/feature-flag' do ...@@ -13,7 +13,7 @@ RSpec.describe 'bin/feature-flag' do
let(:options) { FeatureFlagOptionParser.parse(argv) } let(:options) { FeatureFlagOptionParser.parse(argv) }
let(:creator) { described_class.new(options) } let(:creator) { described_class.new(options) }
let(:existing_flags) do let(:existing_flags) do
{ 'existing-feature-flag' => File.join('config', 'feature_flags', 'development', 'existing-feature-flag.yml') } { 'existing_feature_flag' => File.join('config', 'feature_flags', 'development', 'existing_feature_flag.yml') }
end end
before do before do
...@@ -32,12 +32,12 @@ RSpec.describe 'bin/feature-flag' do ...@@ -32,12 +32,12 @@ RSpec.describe 'bin/feature-flag' do
it 'properly creates a feature flag' do it 'properly creates a feature flag' do
expect(File).to receive(:write).with( expect(File).to receive(:write).with(
File.join('config', 'feature_flags', 'development', 'feature-flag-name.yml'), File.join('config', 'feature_flags', 'development', 'feature_flag_name.yml'),
anything) anything)
expect do expect do
subject subject
end.to output(/name: feature-flag-name/).to_stdout end.to output(/name: feature_flag_name/).to_stdout
end end
context 'when running on master' do context 'when running on master' do
......
...@@ -27,13 +27,17 @@ RSpec.describe Gitlab::Graphql::Authorize::AuthorizeFieldService do ...@@ -27,13 +27,17 @@ RSpec.describe Gitlab::Graphql::Authorize::AuthorizeFieldService do
end end
end end
def resolve
service.authorized_resolve[type_instance, {}, context]
end
subject(:service) { described_class.new(field) } subject(:service) { described_class.new(field) }
describe '#authorized_resolve' do describe '#authorized_resolve' do
let_it_be(:current_user) { build(:user) } let_it_be(:current_user) { build(:user) }
let_it_be(:presented_object) { 'presented object' } let_it_be(:presented_object) { 'presented object' }
let_it_be(:query_type) { GraphQL::ObjectType.new } let_it_be(:query_type) { GraphQL::ObjectType.new }
let_it_be(:schema) { GraphQL::Schema.define(query: query_type, mutation: nil)} let_it_be(:schema) { GitlabSchema }
let_it_be(:query) { GraphQL::Query.new(schema, document: nil, context: {}, variables: {}) } let_it_be(:query) { GraphQL::Query.new(schema, document: nil, context: {}, variables: {}) }
let_it_be(:context) { GraphQL::Query::Context.new(query: query, values: { current_user: current_user }, object: nil) } let_it_be(:context) { GraphQL::Query::Context.new(query: query, values: { current_user: current_user }, object: nil) }
...@@ -41,8 +45,69 @@ RSpec.describe Gitlab::Graphql::Authorize::AuthorizeFieldService do ...@@ -41,8 +45,69 @@ RSpec.describe Gitlab::Graphql::Authorize::AuthorizeFieldService do
let(:type_instance) { type_class.authorized_new(presented_object, context) } let(:type_instance) { type_class.authorized_new(presented_object, context) }
let(:field) { type_class.fields['testField'].to_graphql } let(:field) { type_class.fields['testField'].to_graphql }
subject(:resolved) { service.authorized_resolve.call(type_instance, {}, context) } subject(:resolved) { resolve&.force }
context 'reading the field of a lazy value' do
let(:ability) { :read_field }
let(:presented_object) { lazy_upcase('a') }
let(:type_class) { type_with_field(GraphQL::STRING_TYPE, ability) }
let(:upcaser) do
Module.new do
def self.upcase(strs)
strs.map(&:upcase)
end
end
end
def lazy_upcase(str)
::BatchLoader::GraphQL.for(str).batch do |strs, found|
strs.zip(upcaser.upcase(strs)).each { |s, us| found[s, us] }
end
end
it 'does not run authorizations until we force the resolved value' do
expect(Ability).not_to receive(:allowed?)
expect(resolve).to respond_to(:force)
end
it 'runs authorizations when we force the resolved value' do
spy_ability_check_for(ability, 'A')
expect(resolved).to eq('Resolved value')
end
it 'redacts values that fail the permissions check' do
spy_ability_check_for(ability, 'A', passed: false)
expect(resolved).to be_nil
end
context 'we batch two calls' do
def resolve(value)
instance = type_class.authorized_new(lazy_upcase(value), context)
service.authorized_resolve[instance, {}, context]
end
it 'batches resolution, but authorizes each object separately' do
expect(upcaser).to receive(:upcase).once.and_call_original
spy_ability_check_for(:read_field, 'A', passed: true)
spy_ability_check_for(:read_field, 'B', passed: false)
spy_ability_check_for(:read_field, 'C', passed: true)
a = resolve('a')
b = resolve('b')
c = resolve('c')
expect(a.force).to be_present
expect(b.force).to be_nil
expect(c.force).to be_present
end
end
end
shared_examples 'authorizing fields' do
context 'scalar types' do context 'scalar types' do
shared_examples 'checking permissions on the presented object' do shared_examples 'checking permissions on the presented object' do
it 'checks the abilities on the object being presented and returns the value' do it 'checks the abilities on the object being presented and returns the value' do
...@@ -97,6 +162,20 @@ RSpec.describe Gitlab::Graphql::Authorize::AuthorizeFieldService do ...@@ -97,6 +162,20 @@ RSpec.describe Gitlab::Graphql::Authorize::AuthorizeFieldService do
expect(resolved).to be_nil expect(resolved).to be_nil
end end
end end
context 'when it returns values' do
let(:objects) { [1, 2, 3] }
let(:field_type) { type([:read_object]).connection_type }
let(:type_class) { type_with_field(field_type, [], objects) }
it 'filters out unauthorized values' do
spy_ability_check_for(:read_object, 1, passed: true)
spy_ability_check_for(:read_object, 2, passed: false)
spy_ability_check_for(:read_object, 3, passed: true)
expect(resolved.nodes).to eq [1, 3]
end
end
end end
context 'when the field is a specific type' do context 'when the field is a specific type' do
...@@ -106,8 +185,6 @@ RSpec.describe Gitlab::Graphql::Authorize::AuthorizeFieldService do ...@@ -106,8 +185,6 @@ RSpec.describe Gitlab::Graphql::Authorize::AuthorizeFieldService do
let(:type_class) { type_with_field(custom_type, :read_field, object_in_field) } let(:type_class) { type_with_field(custom_type, :read_field, object_in_field) }
let(:type_instance) { type_class.authorized_new(object_in_field, context) } let(:type_instance) { type_class.authorized_new(object_in_field, context) }
subject(:resolved) { service.authorized_resolve.call(type_instance, {}, context) }
it 'checks both field & type permissions' do it 'checks both field & type permissions' do
spy_ability_check_for(:read_field, object_in_field, passed: true) spy_ability_check_for(:read_field, object_in_field, passed: true)
spy_ability_check_for(:read_type, object_in_field, passed: true) spy_ability_check_for(:read_type, object_in_field, passed: true)
...@@ -156,9 +233,22 @@ RSpec.describe Gitlab::Graphql::Authorize::AuthorizeFieldService do ...@@ -156,9 +233,22 @@ RSpec.describe Gitlab::Graphql::Authorize::AuthorizeFieldService do
spy_ability_check_for(:read_type, object_1, passed: false) spy_ability_check_for(:read_type, object_1, passed: false)
expect(resolved.map(&:object)).to contain_exactly(object_2) expect(resolved).to contain_exactly(have_attributes(object: object_2))
end
end
end end
end end
it_behaves_like 'authorizing fields'
context 'the graphql_lazy_authorization feature flag is disabled' do
before do
stub_feature_flags(graphql_lazy_authorization: false)
end
subject(:resolved) { resolve }
it_behaves_like 'authorizing fields'
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Graphql::Lazy do
def load(key)
BatchLoader.for(key).batch do |keys, loader|
keys.each { |x| loader.call(x, x * x) }
end
end
let(:value) { double(x: 1) }
describe '#force' do
subject { described_class.new { value.x } }
it 'can extract the value' do
expect(subject.force).to be 1
end
it 'can derive new lazy values' do
expect(subject.then { |x| x + 2 }.force).to be 3
end
it 'only evaluates once' do
expect(value).to receive(:x).once
expect(subject.force).to eq(subject.force)
end
it 'deals with nested laziness' do
expect(described_class.new { load(10) }.force).to eq(100)
expect(described_class.new { described_class.new { 5 } }.force).to eq 5
end
end
describe '.with_value' do
let(:inner) { described_class.new { value.x } }
subject { described_class.with_value(inner) { |x| x.to_s } }
it 'defers the application of a block to a value' do
expect(value).not_to receive(:x)
expect(subject).to be_an_instance_of(described_class)
end
it 'evaluates to the application of the block to the value' do
expect(value).to receive(:x).once
expect(subject.force).to eq(inner.force.to_s)
end
end
describe '.force' do
context 'when given a plain value' do
subject { described_class.force(1) }
it 'unwraps the value' do
expect(subject).to be 1
end
end
context 'when given a wrapped lazy value' do
subject { described_class.force(described_class.new { 2 }) }
it 'unwraps the value' do
expect(subject).to be 2
end
end
context 'when the value is from a batchloader' do
subject { described_class.force(load(3)) }
it 'syncs the value' do
expect(subject).to be 9
end
end
context 'when the value is a GraphQL lazy' do
subject { described_class.force(GitlabSchema.after_lazy(load(3)) { |x| x + 1 } ) }
it 'forces the evaluation' do
expect(subject).to be 10
end
end
context 'when the value is a promise' do
subject { described_class.force(::Concurrent::Promise.new { 4 }) }
it 'executes the promise and waits for the value' do
expect(subject).to be 4
end
end
end
end
...@@ -478,6 +478,8 @@ module GraphqlHelpers ...@@ -478,6 +478,8 @@ module GraphqlHelpers
use Gitlab::Graphql::Authorize use Gitlab::Graphql::Authorize
use Gitlab::Graphql::Pagination::Connections use Gitlab::Graphql::Pagination::Connections
lazy_resolve ::Gitlab::Graphql::Lazy, :force
query(query_type) query(query_type)
end end
......
...@@ -106,13 +106,11 @@ RSpec.shared_examples 'querying a GraphQL type with labels' do ...@@ -106,13 +106,11 @@ RSpec.shared_examples 'querying a GraphQL type with labels' do
end end
it 'batches queries for labels by title' do it 'batches queries for labels by title' do
pending('See: https://gitlab.com/gitlab-org/gitlab/-/issues/217767')
multi_selection = query_for(label_b, label_c) multi_selection = query_for(label_b, label_c)
single_selection = query_for(label_d) single_selection = query_for(label_d)
expect { run_query(multi_selection) } expect { run_query(multi_selection) }
.to issue_same_number_of_queries_as { run_query(single_selection) } .to issue_same_number_of_queries_as { run_query(single_selection) }.ignoring_cached_queries
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment