forest_liana 9.22.5 → 9.22.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/services/forest_liana/ability/permission.rb +30 -5
- data/app/services/forest_liana/search_query_builder.rb +6 -2
- data/lib/forest_liana/version.rb +1 -1
- data/spec/dummy/lib/forest_liana/collections/boat.rb +10 -0
- data/spec/dummy/lib/forest_liana/collections/captain.rb +10 -0
- data/spec/requests/smart_field_named_like_unexposed_relation_spec.rb +125 -0
- data/spec/services/forest_liana/ability/assert_can_read_query_fields_spec.rb +58 -0
- data/spec/services/forest_liana/ability/redact_fields_spec.rb +95 -0
- data/spec/services/forest_liana/search_query_builder_spec.rb +83 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bf311206c7fdea9b79789aba4523930ddccdf1904826d04c36e5e72995eb8640
|
|
4
|
+
data.tar.gz: 8f1aff957bd3471d3766ec99e5d1ac0e7c808fc96b7193432efde480c85cbfbe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: edd1873169d8762a7c8687de26c98dd062aca95b3a2c522f6c9ac8c1167ef9d9c26810c148dd6113053414b5cc7e5ae63b3d722ba6a12938f141f1d9a8e2fa5e
|
|
7
|
+
data.tar.gz: f80f9add4df869e43af24d2944e379d8deb2b27207b66d7fa1ec1e8bc91c8be8dcd4c570ce01ae06423823756cf65574d0c49c57de020626123c0b79726bd718
|
|
@@ -335,18 +335,40 @@ module ForestLiana
|
|
|
335
335
|
# association, so FieldPath would otherwise resolve it to a column of +model+ itself — the
|
|
336
336
|
# collection its `reference` actually points to is checked instead, the same target
|
|
337
337
|
# `fields_per_model` already resolves a caller-named smart relation to.
|
|
338
|
-
def resolve_owner(model, field_name)
|
|
338
|
+
def resolve_owner(model, field_name, smart_field_fallback: true)
|
|
339
339
|
smart_field = smart_belongs_to_field(model, field_name)
|
|
340
340
|
|
|
341
341
|
return [smart_field[:reference].split('.').first] if smart_field
|
|
342
342
|
|
|
343
|
-
FieldPath.leaf_collection_names(model, field_name)
|
|
343
|
+
targets = FieldPath.leaf_collection_names(model, field_name)
|
|
344
|
+
return targets if targets.any? { |name| collection_exposed?(name) }
|
|
345
|
+
|
|
346
|
+
# No relation to an unexposed target reaches the caller, so a computed smart field of the same
|
|
347
|
+
# name is the only field this name can mean: its block reads the root row. A polymorphic
|
|
348
|
+
# relation is kept whatever its targets, and keeps its check.
|
|
349
|
+
return [ForestLiana.name_for(model)] if smart_field_fallback && only_computed_smart_field?(model, field_name, targets)
|
|
350
|
+
|
|
351
|
+
targets
|
|
344
352
|
end
|
|
345
353
|
|
|
346
354
|
def smart_belongs_to_field(model, field_name)
|
|
347
|
-
|
|
355
|
+
forest_collection_for(model)&.fields_smart_belongs_to&.find { |field| field[:field].to_s == field_name }
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# SchemaAdapter drops a relation to an excluded model, but keeps one whose target is missing from
|
|
359
|
+
# the apimap otherwise (a table absent at boot, a secondary database unreachable): skipped alike.
|
|
360
|
+
# A polymorphic relation's reference names the association, never one of its targets, so it stays.
|
|
361
|
+
def only_computed_smart_field?(model, field_name, unexposed_targets)
|
|
362
|
+
fields = forest_collection_for(model)&.fields.to_a.select { |field| field[:field].to_s == field_name }
|
|
363
|
+
fields = fields.reject do |field|
|
|
364
|
+
!field[:is_virtual] && unexposed_targets.include?(field[:reference].to_s.split('.').first)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
fields.any? && fields.all? { |field| field[:is_virtual] && field[:reference].nil? && field[:integration].nil? }
|
|
368
|
+
end
|
|
348
369
|
|
|
349
|
-
|
|
370
|
+
def forest_collection_for(model)
|
|
371
|
+
ForestLiana.apimap.find { |collection| collection.name.to_s == ForestLiana.name_for(model) }
|
|
350
372
|
end
|
|
351
373
|
|
|
352
374
|
# No role can ever be granted `read` on a collection absent from the apimap — a denial
|
|
@@ -378,7 +400,10 @@ module ForestLiana
|
|
|
378
400
|
# partition (not split) so an empty or colon-only path resolves to '' (the root, pinned
|
|
379
401
|
# readable) instead of nil.
|
|
380
402
|
def query_target_collections(root_model, path)
|
|
381
|
-
|
|
403
|
+
head, separator, = path.partition(':')
|
|
404
|
+
# A traversal names the relation itself, never a smart field: a String has no sub-field, and
|
|
405
|
+
# what the query joins for it is the relation's table.
|
|
406
|
+
resolve_owner(root_model, head, smart_field_fallback: separator.empty?)
|
|
382
407
|
end
|
|
383
408
|
|
|
384
409
|
def query_usage(action, root_model, path)
|
|
@@ -15,6 +15,7 @@ module ForestLiana
|
|
|
15
15
|
@search = @params[:search].presence
|
|
16
16
|
@user = user
|
|
17
17
|
@filter_joins = []
|
|
18
|
+
@search_field_paths = []
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
def perform(resource)
|
|
@@ -31,6 +32,7 @@ module ForestLiana
|
|
|
31
32
|
# already excludes itself on it and so can never be the reason @conditions_pushed is true.
|
|
32
33
|
@conditions_pushed = false
|
|
33
34
|
@lambda_contributed = false
|
|
35
|
+
@search_footprint_tracked = false
|
|
34
36
|
@records = search_param
|
|
35
37
|
|
|
36
38
|
caller_filter = @params[:filters].present? ? ForestLiana::ScopeManager.inject_context_variables(@params[:filters], @user) : nil
|
|
@@ -90,12 +92,13 @@ module ForestLiana
|
|
|
90
92
|
# hadn't run at all — this can't currently tell "ran and did nothing" apart from "ran and
|
|
91
93
|
# found real rows". Logged rather than silently discarded, since nothing else would ever
|
|
92
94
|
# surface it to whoever built the smart-search hook.
|
|
93
|
-
if @lambda_contributed && !@conditions_pushed && malformed_uuid_search?
|
|
95
|
+
if @search_footprint_tracked && @lambda_contributed && !@conditions_pushed && malformed_uuid_search?
|
|
94
96
|
FOREST_LOGGER.info "A smart-search lambda's result on the \"#{ForestLiana.name_for(root_model)}\" " \
|
|
95
97
|
"collection was discarded: the search term (#{@search.inspect}) is UUID-shaped but " \
|
|
96
98
|
'invalid, and no other condition constrained the query.'
|
|
97
99
|
end
|
|
98
|
-
|
|
100
|
+
# A host override of search_param that never calls super pushes no condition: its result is served as is.
|
|
101
|
+
@records = @records.none if @search_footprint_tracked && !@conditions_pushed && !(@lambda_contributed && !malformed_uuid_search?)
|
|
99
102
|
end
|
|
100
103
|
|
|
101
104
|
@records = sort_query
|
|
@@ -125,6 +128,7 @@ module ForestLiana
|
|
|
125
128
|
end
|
|
126
129
|
|
|
127
130
|
def search_param
|
|
131
|
+
@search_footprint_tracked = true
|
|
128
132
|
@search_field_paths = []
|
|
129
133
|
|
|
130
134
|
if @search
|
data/lib/forest_liana/version.rb
CHANGED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require 'rails_helper'
|
|
2
|
+
|
|
3
|
+
# Forest::Boat#harbor and Forest::Captain#license share their name with a relation of the model.
|
|
4
|
+
# With the relation's target left out of the apimap, SchemaAdapter drops the relation and only the
|
|
5
|
+
# smart field remains: the request reads it off the root row, not off the unexposed target.
|
|
6
|
+
describe 'A computed smart field named like a relation to an unexposed collection', type: :request do
|
|
7
|
+
token = JWT.encode({ id: 38, email: 'michael.kelso@that70.show', first_name: 'Michael',
|
|
8
|
+
last_name: 'Kelso', team: 'Operations', rendering_id: 16,
|
|
9
|
+
exp: Time.now.to_i + 2.weeks.to_i, permission_level: 'admin' },
|
|
10
|
+
ForestLiana.auth_secret, 'HS256')
|
|
11
|
+
headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json',
|
|
12
|
+
'Authorization' => "Bearer #{token}" }
|
|
13
|
+
page = { 'number' => '1', 'size' => '15' }
|
|
14
|
+
|
|
15
|
+
let!(:harbor) { Harbor.create!(name: 'Saint-Malo') }
|
|
16
|
+
let!(:captain) { Captain.create!(name: 'Haddock', license_number: 'FR-4421') }
|
|
17
|
+
let!(:license) { License.create!(number: 'FR-4421', expires_on: Date.new(2030, 1, 1)) }
|
|
18
|
+
let!(:boat) { Boat.create!(name: 'Karaboudjan', harbor: harbor, captain: captain) }
|
|
19
|
+
|
|
20
|
+
let(:unexposed) { [] }
|
|
21
|
+
let(:relations_kept) { false }
|
|
22
|
+
|
|
23
|
+
before do
|
|
24
|
+
allow(ForestLiana::IpWhitelist).to receive(:retrieve) { true }
|
|
25
|
+
allow(ForestLiana::IpWhitelist).to receive(:is_ip_whitelist_retrieved) { true }
|
|
26
|
+
allow(ForestLiana::IpWhitelist).to receive(:is_ip_valid) { true }
|
|
27
|
+
allow_any_instance_of(ForestLiana::Ability).to receive(:forest_authorize!) { true }
|
|
28
|
+
allow(ForestLiana::ScopeManager).to receive(:fetch_scopes)
|
|
29
|
+
.and_return('scopes' => {}, 'team' => { 'id' => '1', 'name' => 'Operations' })
|
|
30
|
+
allow(ForestLiana).to receive(:apimap).and_wrap_original do |original|
|
|
31
|
+
original.call.reject { |collection| unexposed.include?(collection.name.to_s) }
|
|
32
|
+
end
|
|
33
|
+
# The apimap was built at boot with every target exposed: drop the relations to the unexposed
|
|
34
|
+
# ones, as SchemaAdapter does for a model left out of it.
|
|
35
|
+
ForestLiana.apimap.each do |collection|
|
|
36
|
+
allow(collection).to receive(:fields).and_wrap_original do |original|
|
|
37
|
+
next original.call if relations_kept
|
|
38
|
+
|
|
39
|
+
original.call.reject { |field| unexposed.include?(field[:reference].to_s.split('.').first) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def body
|
|
45
|
+
JSON.parse(response.body)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
shared_examples 'serving the smart field off the root' do
|
|
49
|
+
context 'when the belongs_to target is unexposed' do
|
|
50
|
+
let(:unexposed) { ['Harbor'] }
|
|
51
|
+
|
|
52
|
+
it 'serves the list' do
|
|
53
|
+
get '/forest/Boat', params: { fields: { 'Boat' => 'id,name,harbor' }, page: page, timezone: 'Europe/Paris' },
|
|
54
|
+
headers: headers
|
|
55
|
+
|
|
56
|
+
expect(response.status).to eq(200)
|
|
57
|
+
expect(body['data'].map { |row| row['attributes']['harbor'] }).to eq(['Saint-Malo'])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'serves the get-one' do
|
|
61
|
+
get "/forest/Boat/#{boat.id}", params: { fields: { 'Boat' => 'id,name,harbor' }, timezone: 'Europe/Paris' },
|
|
62
|
+
headers: headers
|
|
63
|
+
|
|
64
|
+
expect(response.status).to eq(200)
|
|
65
|
+
expect(body['data']['attributes']['harbor']).to eq('Saint-Malo')
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
context 'when the has_one target is unexposed' do
|
|
70
|
+
let(:unexposed) { ['License'] }
|
|
71
|
+
|
|
72
|
+
it 'serves the list' do
|
|
73
|
+
get '/forest/Captain', params: { fields: { 'Captain' => 'id,name,license' }, page: page, timezone: 'Europe/Paris' },
|
|
74
|
+
headers: headers
|
|
75
|
+
|
|
76
|
+
expect(response.status).to eq(200)
|
|
77
|
+
expect(body['data'].map { |row| row['attributes']['license'] }).to eq(['FR-4421'])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'serves the get-one' do
|
|
81
|
+
get "/forest/Captain/#{captain.id}", params: { fields: { 'Captain' => 'id,name,license' }, timezone: 'Europe/Paris' },
|
|
82
|
+
headers: headers
|
|
83
|
+
|
|
84
|
+
expect(response.status).to eq(200)
|
|
85
|
+
expect(body['data']['attributes']['license']).to eq('FR-4421')
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A target missing from the apimap without being excluded (a table absent at boot, a secondary
|
|
91
|
+
# database unreachable) leaves its relation in the schema next to the smart field.
|
|
92
|
+
context 'when the relation to the unexposed target stays in the schema' do
|
|
93
|
+
let(:relations_kept) { true }
|
|
94
|
+
|
|
95
|
+
before { Rails.cache.write('forest.has_permission', false) }
|
|
96
|
+
|
|
97
|
+
include_examples 'serving the smart field off the root'
|
|
98
|
+
|
|
99
|
+
it "still refuses the unexposed target's own fields" do
|
|
100
|
+
unexposed << 'Harbor'
|
|
101
|
+
|
|
102
|
+
get '/forest/Boat', params: { fields: { 'Boat' => 'id,name,harbor', 'harbor' => 'name' }, page: page,
|
|
103
|
+
timezone: 'Europe/Paris' },
|
|
104
|
+
headers: headers
|
|
105
|
+
|
|
106
|
+
expect(response.status).to eq(403)
|
|
107
|
+
expect(body['errors'].first['data']['unexposed_fields']).to eq(['name'])
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
context 'with skip_relation_read_permissions, as Qonto runs it' do
|
|
112
|
+
before do
|
|
113
|
+
allow(ForestLiana).to receive(:skip_relation_read_permissions?) { true }
|
|
114
|
+
Rails.cache.write('forest.has_permission', true)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
include_examples 'serving the smart field off the root'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
context 'without a permission system' do
|
|
121
|
+
before { Rails.cache.write('forest.has_permission', false) }
|
|
122
|
+
|
|
123
|
+
include_examples 'serving the smart field off the root'
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -284,6 +284,64 @@ module ForestLiana
|
|
|
284
284
|
end
|
|
285
285
|
end
|
|
286
286
|
|
|
287
|
+
# PRD-1429: with `island` left out of the apimap, a computed smart field of the same name is
|
|
288
|
+
# what a bare path means. A traversal still means the relation, whose table the query joins.
|
|
289
|
+
describe 'a computed smart field sharing its name with a relation absent from the apimap' do
|
|
290
|
+
before do
|
|
291
|
+
forest_collection = double('forest_collection')
|
|
292
|
+
allow(forest_collection).to receive(:name).and_return('Tree')
|
|
293
|
+
allow(forest_collection).to receive(:fields_smart_belongs_to).and_return([])
|
|
294
|
+
allow(forest_collection).to receive(:fields).and_return([{ field: :island, is_virtual: true }])
|
|
295
|
+
allow(ForestLiana).to receive(:apimap).and_return([forest_collection])
|
|
296
|
+
write_permissions('Tree' => true)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
it 'serves a sort on the smart field off the root' do
|
|
300
|
+
expect { dummy_class.assert_can_read_query_fields(user, Tree, sort_paths: ['island']) }
|
|
301
|
+
.not_to raise_error
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
it 'still refuses a filter traversing the relation' do
|
|
305
|
+
expect { dummy_class.assert_can_read_query_fields(user, Tree, filter_paths: ['island:name']) }
|
|
306
|
+
.to raise_error(ForestLiana::Ability::Exceptions::UnexposedQueryCollectionError)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
context 'when the relation stays in the schema next to the smart field' do
|
|
310
|
+
before do
|
|
311
|
+
allow(ForestLiana.apimap.first).to receive(:fields).and_return(
|
|
312
|
+
[{ field: :island, reference: 'Island.id', relationship: 'BelongsTo', is_virtual: false },
|
|
313
|
+
{ field: :island, is_virtual: true }]
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
it 'serves a sort on the smart field off the root all the same' do
|
|
318
|
+
expect { dummy_class.assert_can_read_query_fields(user, Tree, sort_paths: ['island']) }
|
|
319
|
+
.not_to raise_error
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# A polymorphic relation's reference names the association (`addressable.id`), never one of
|
|
325
|
+
# its targets, so it stays next to the smart field and keeps the name.
|
|
326
|
+
describe 'a computed smart field sharing its name with a polymorphic relation to unexposed targets' do
|
|
327
|
+
before do
|
|
328
|
+
forest_collection = double('forest_collection')
|
|
329
|
+
allow(forest_collection).to receive(:name).and_return('Address')
|
|
330
|
+
allow(forest_collection).to receive(:fields_smart_belongs_to).and_return([])
|
|
331
|
+
allow(forest_collection).to receive(:fields).and_return(
|
|
332
|
+
[{ field: 'addressable', reference: 'addressable.id', relationship: 'BelongsTo', is_virtual: false },
|
|
333
|
+
{ field: :addressable, is_virtual: true }]
|
|
334
|
+
)
|
|
335
|
+
allow(ForestLiana).to receive(:apimap).and_return([forest_collection])
|
|
336
|
+
write_permissions('Address' => true)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
it 'keeps refusing a sort on it' do
|
|
340
|
+
expect { dummy_class.assert_can_read_query_fields(user, Address, sort_paths: ['addressable']) }
|
|
341
|
+
.to raise_error(ForestLiana::Ability::Exceptions::UnexposedQueryCollectionError)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
287
345
|
describe 'polymorphic' do
|
|
288
346
|
it 'serves a filter on a relation whose every target is readable' do
|
|
289
347
|
write_permissions('Address' => true, 'User' => true, 'Island' => true)
|
|
@@ -299,13 +299,56 @@ module ForestLiana
|
|
|
299
299
|
end
|
|
300
300
|
|
|
301
301
|
describe 'a collection absent from the apimap' do
|
|
302
|
+
let(:fields) { [] }
|
|
303
|
+
|
|
302
304
|
before do
|
|
303
305
|
forest_collection = double('forest_collection')
|
|
304
306
|
allow(forest_collection).to receive(:name).and_return('Tree')
|
|
305
307
|
allow(forest_collection).to receive(:fields_smart_belongs_to).and_return([])
|
|
308
|
+
allow(forest_collection).to receive(:fields).and_return(fields)
|
|
306
309
|
allow(ForestLiana).to receive(:apimap).and_return([forest_collection])
|
|
307
310
|
end
|
|
308
311
|
|
|
312
|
+
# SchemaAdapter drops `island` from the schema here, leaving a computed smart field of the
|
|
313
|
+
# same name as the only field the caller can mean (PRD-1429).
|
|
314
|
+
context 'when a computed smart field shares its name with the relation' do
|
|
315
|
+
let(:fields) { [{ field: :island, is_virtual: true }] }
|
|
316
|
+
|
|
317
|
+
it 'serves it off the root instead of refusing the request' do
|
|
318
|
+
write_permissions('Tree' => true)
|
|
319
|
+
|
|
320
|
+
expect(dummy_class.redact_fields(user, Tree, { 'Tree' => 'name,island' }, named_collections: ['Tree']))
|
|
321
|
+
.to eq('Tree' => 'name,island')
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# SchemaAdapter keeps the relation when Island is missing from the apimap without being
|
|
326
|
+
# excluded (a table absent at boot, a secondary database unreachable).
|
|
327
|
+
context 'when the relation stays in the schema next to the smart field' do
|
|
328
|
+
let(:fields) do
|
|
329
|
+
[{ field: :island, reference: 'Island.id', relationship: 'BelongsTo', is_virtual: false },
|
|
330
|
+
{ field: :island, is_virtual: true }]
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
it 'serves the smart field off the root all the same' do
|
|
334
|
+
write_permissions('Tree' => true)
|
|
335
|
+
|
|
336
|
+
expect(dummy_class.redact_fields(user, Tree, { 'Tree' => 'name,island' }, named_collections: ['Tree']))
|
|
337
|
+
.to eq('Tree' => 'name,island')
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
context 'when only the relation stays in the schema' do
|
|
342
|
+
let(:fields) { [{ field: :island, reference: 'Island.id', relationship: 'BelongsTo', is_virtual: false }] }
|
|
343
|
+
|
|
344
|
+
it 'keeps refusing it' do
|
|
345
|
+
write_permissions('Tree' => true)
|
|
346
|
+
|
|
347
|
+
expect { dummy_class.redact_fields(user, Tree, { 'Tree' => 'island' }, named_collections: ['Tree']) }
|
|
348
|
+
.to raise_error(ForestLiana::Ability::Exceptions::UnauthorizedFieldsError)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
309
352
|
it 'names it as unexposed rather than as merely denied, since no role can be granted read on it' do
|
|
310
353
|
write_permissions('Tree' => true, 'Island' => false)
|
|
311
354
|
|
|
@@ -328,6 +371,58 @@ module ForestLiana
|
|
|
328
371
|
end
|
|
329
372
|
end
|
|
330
373
|
|
|
374
|
+
# The exposed relation still reaches the response alongside the smart field, so its read
|
|
375
|
+
# check stands: only an unexposed target hands the name over to the smart field.
|
|
376
|
+
describe 'a computed smart field sharing its name with an exposed relation' do
|
|
377
|
+
before do
|
|
378
|
+
forest_collection = double('forest_collection')
|
|
379
|
+
allow(forest_collection).to receive(:name).and_return('Tree')
|
|
380
|
+
allow(forest_collection).to receive(:fields_smart_belongs_to).and_return([])
|
|
381
|
+
allow(forest_collection).to receive(:fields).and_return([{ field: :island, is_virtual: true }])
|
|
382
|
+
island_collection = double('island_collection')
|
|
383
|
+
allow(island_collection).to receive(:name).and_return('Island')
|
|
384
|
+
allow(ForestLiana).to receive(:apimap).and_return([forest_collection, island_collection])
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
it 'keeps checking the relation' do
|
|
388
|
+
write_permissions('Tree' => true, 'Island' => false)
|
|
389
|
+
|
|
390
|
+
expect { dummy_class.redact_fields(user, Tree, { 'Tree' => 'island' }, named_collections: ['Tree']) }
|
|
391
|
+
.to raise_error(ForestLiana::Ability::Exceptions::UnauthorizedFieldsError) do |error|
|
|
392
|
+
expect(error.message).to eq("You are not allowed to read 'island' from the 'Island' collection.")
|
|
393
|
+
expect(error.data[:unexposed_fields]).to be_nil
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# SchemaAdapter keeps a polymorphic relation in the schema even when none of its targets is
|
|
399
|
+
# exposed, so a computed smart field of the same name does not take the name over.
|
|
400
|
+
describe 'a computed smart field sharing its name with a polymorphic relation to unexposed targets' do
|
|
401
|
+
before do
|
|
402
|
+
forest_collection = double('forest_collection')
|
|
403
|
+
allow(forest_collection).to receive(:name).and_return('Address')
|
|
404
|
+
allow(forest_collection).to receive(:fields_smart_belongs_to).and_return([])
|
|
405
|
+
allow(forest_collection).to receive(:fields).and_return(
|
|
406
|
+
[{ field: 'addressable', reference: 'addressable.id', relationship: 'BelongsTo', is_virtual: false },
|
|
407
|
+
{ field: :addressable, is_virtual: true }]
|
|
408
|
+
)
|
|
409
|
+
allow(ForestLiana).to receive(:apimap).and_return([forest_collection])
|
|
410
|
+
write_permissions('Address' => true)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
it 'keeps refusing the root field' do
|
|
414
|
+
expect { dummy_class.redact_fields(user, Address, { 'Address' => 'line1,addressable' }, named_collections: ['Address']) }
|
|
415
|
+
.to raise_error(ForestLiana::Ability::Exceptions::UnauthorizedFieldsError)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
it "keeps refusing the targets' own fields" do
|
|
419
|
+
expect do
|
|
420
|
+
dummy_class.redact_fields(user, Address, { 'Address' => 'line1', 'addressable' => 'name' },
|
|
421
|
+
named_collections: %w[Address addressable])
|
|
422
|
+
end.to raise_error(ForestLiana::Ability::Exceptions::UnauthorizedFieldsError)
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
331
426
|
# Every real collection stays in the apimap here (only Island is rejected), unlike the
|
|
332
427
|
# describe block above — so 'owner' resolves to an exposed-but-denied User, not a second
|
|
333
428
|
# unexposed collection, and the two entries' differently-shaped clauses still join as one
|
|
@@ -540,6 +540,89 @@ module ForestLiana
|
|
|
540
540
|
end
|
|
541
541
|
end
|
|
542
542
|
|
|
543
|
+
describe '#initialize' do
|
|
544
|
+
it 'starts with an empty search footprint' do
|
|
545
|
+
expect(builder.search_field_paths).to eq([])
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
describe 'when a host app overrides search_param without calling super' do
|
|
550
|
+
let(:builder_class) do
|
|
551
|
+
Class.new(described_class) do
|
|
552
|
+
def search_param
|
|
553
|
+
return @resource unless @search
|
|
554
|
+
|
|
555
|
+
@resource.where('LOWER(trees.name) LIKE ?', "%#{@search.downcase}%")
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
let(:builder) { builder_class.new(params, [], collection, user) }
|
|
560
|
+
|
|
561
|
+
before do
|
|
562
|
+
Tree.create!(name: 'Oak')
|
|
563
|
+
Tree.create!(name: 'Pine')
|
|
564
|
+
end
|
|
565
|
+
after { Tree.destroy_all }
|
|
566
|
+
|
|
567
|
+
context 'without a search' do
|
|
568
|
+
let(:params) { { searchExtended: '0' } }
|
|
569
|
+
|
|
570
|
+
it 'serves every record' do
|
|
571
|
+
expect(builder.perform(Tree.all).count).to eq(2)
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
context 'with a search the override matches' do
|
|
576
|
+
let(:params) { { search: 'oak', searchExtended: '0' } }
|
|
577
|
+
|
|
578
|
+
it "serves the override's result instead of emptying it" do
|
|
579
|
+
expect(builder.perform(Tree.all).map(&:name)).to eq(['Oak'])
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
context 'with a smart-search lambda and a malformed-UUID-shaped search' do
|
|
584
|
+
let(:builder_class) do
|
|
585
|
+
Class.new(described_class) do
|
|
586
|
+
def search_param
|
|
587
|
+
@resource
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
before do
|
|
593
|
+
allow(ForestLiana).to receive(:schema_for_resource).and_return(
|
|
594
|
+
ForestLiana::Model::Collection.new(
|
|
595
|
+
name: 'Tree', fields: [{ field: :custom, type: 'String', search: ->(query, _search) { query.where(name: 'Oak') } }]
|
|
596
|
+
)
|
|
597
|
+
)
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
let(:params) { { search: 'abcdef12-3456-4ae-ad4f-5662757713a2', searchExtended: '0' } }
|
|
601
|
+
|
|
602
|
+
it "serves the lambda's result without logging it as discarded" do
|
|
603
|
+
expect(FOREST_LOGGER).not_to receive(:info).with(/was discarded/)
|
|
604
|
+
|
|
605
|
+
expect(builder.perform(Tree.all).map(&:name)).to eq(['Oak'])
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
context 'when the override calls super' do
|
|
610
|
+
let(:builder_class) do
|
|
611
|
+
Class.new(described_class) do
|
|
612
|
+
def search_param
|
|
613
|
+
super
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
let(:collection) { ForestLiana::Model::Collection.new(name: 'Tree', fields: [], search_fields: ['nonexistent']) }
|
|
618
|
+
let(:params) { { search: 'nothing-matches-this', searchExtended: '0' } }
|
|
619
|
+
|
|
620
|
+
it 'still answers no records when nothing could match' do
|
|
621
|
+
expect(builder.perform(Tree.all).count).to eq(0)
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
|
|
543
626
|
describe 'a blank or whitespace-only search' do
|
|
544
627
|
let(:params) { ActiveSupport::HashWithIndifferentAccess.new(search: ' ', searchExtended: '1') }
|
|
545
628
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: forest_liana
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 9.22.
|
|
4
|
+
version: 9.22.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sandro Munda
|
|
@@ -454,6 +454,8 @@ files:
|
|
|
454
454
|
- spec/dummy/db/schema.rb
|
|
455
455
|
- spec/dummy/db/user_schema.rb
|
|
456
456
|
- spec/dummy/lib/forest_liana/collections/address.rb
|
|
457
|
+
- spec/dummy/lib/forest_liana/collections/boat.rb
|
|
458
|
+
- spec/dummy/lib/forest_liana/collections/captain.rb
|
|
457
459
|
- spec/dummy/lib/forest_liana/collections/island.rb
|
|
458
460
|
- spec/dummy/lib/forest_liana/collections/location.rb
|
|
459
461
|
- spec/dummy/lib/forest_liana/collections/owner.rb
|
|
@@ -484,6 +486,7 @@ files:
|
|
|
484
486
|
- spec/requests/query_footprint_spec.rb
|
|
485
487
|
- spec/requests/related_list_nested_through_spec.rb
|
|
486
488
|
- spec/requests/resources_spec.rb
|
|
489
|
+
- spec/requests/smart_field_named_like_unexposed_relation_spec.rb
|
|
487
490
|
- spec/requests/stats_spec.rb
|
|
488
491
|
- spec/requests/sti_record_conversion_spec.rb
|
|
489
492
|
- spec/requests/test.ru
|
|
@@ -808,6 +811,8 @@ test_files:
|
|
|
808
811
|
- spec/dummy/db/schema.rb
|
|
809
812
|
- spec/dummy/db/user_schema.rb
|
|
810
813
|
- spec/dummy/lib/forest_liana/collections/address.rb
|
|
814
|
+
- spec/dummy/lib/forest_liana/collections/boat.rb
|
|
815
|
+
- spec/dummy/lib/forest_liana/collections/captain.rb
|
|
811
816
|
- spec/dummy/lib/forest_liana/collections/island.rb
|
|
812
817
|
- spec/dummy/lib/forest_liana/collections/location.rb
|
|
813
818
|
- spec/dummy/lib/forest_liana/collections/owner.rb
|
|
@@ -838,6 +843,7 @@ test_files:
|
|
|
838
843
|
- spec/requests/query_footprint_spec.rb
|
|
839
844
|
- spec/requests/related_list_nested_through_spec.rb
|
|
840
845
|
- spec/requests/resources_spec.rb
|
|
846
|
+
- spec/requests/smart_field_named_like_unexposed_relation_spec.rb
|
|
841
847
|
- spec/requests/stats_spec.rb
|
|
842
848
|
- spec/requests/sti_record_conversion_spec.rb
|
|
843
849
|
- spec/requests/test.ru
|