Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions api/lib/vpsadmin/api/locales/cs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -814,11 +814,6 @@ cs:
from_duration:
description: Stránkovat podle trvání
label: Od trvání
from_id:
description: Pokračovat za platbou se zadaným ID v pořadí od nejnovějších
podle času vytvoření. Použij ID poslední platby z předchozí stránky a zachovej
stejné filtry. Pokud platba není dostupná nebo neodpovídá filtrům, vrátí
se prázdný seznam.
from_personal:
description: Odečíst přidané zdroje z osobního balíčku
label: Z osobního balíčku
Expand Down Expand Up @@ -2807,6 +2802,11 @@ cs:
attributes:
created_at:
label: Vytvořeno
from_id:
description: Pokračovat za posledním přiřazením z předchozí stránky. Zachovej
stejné filtry a pořadí. Přiřazení jsou řazena podle from_date a ID ve
zvoleném směru. Nedostupný cursor nebo cursor mimo zvolený rozsah vrátí
HTTP 400.
id:
label: ID
ip_addr:
Expand Down Expand Up @@ -3930,6 +3930,11 @@ cs:
attributes:
created_at:
label: Vytvořeno
from_id:
description: Pokračovat za platbou se zadaným ID v pořadí od nejnovějších
podle času vytvoření. Použij ID poslední platby z předchozí stránky
a zachovej stejné filtry. Pokud platba není dostupná nebo neodpovídá
filtrům, vrátí se prázdný seznam.
id:
label: ID
user_request:
Expand Down
15 changes: 10 additions & 5 deletions api/lib/vpsadmin/api/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -815,11 +815,6 @@ en:
from_duration:
description: Paginate by duration
label: From_duration
from_id:
description: Continue after this payment, ordered by creation time from newest
to oldest. Use the last payment ID from the previous page and keep the same
filters. Returns an empty list if the payment is unavailable or does not
match the filters.
from_personal:
description: Substract the added resources from the personal package
label: From personal package
Expand Down Expand Up @@ -2812,6 +2807,11 @@ en:
attributes:
created_at:
label: Created_at
from_id:
description: Continue after the last assignment from the previous page.
Keep the same filters and order. Assignments are ordered by from_date
and ID in the selected direction. An unavailable or differently scoped
cursor returns HTTP 400.
id:
label: ID
ip_addr:
Expand Down Expand Up @@ -3933,6 +3933,11 @@ en:
attributes:
created_at:
label: Created_at
from_id:
description: Continue after this payment, ordered by creation time from
newest to oldest. Use the last payment ID from the previous page and
keep the same filters. Returns an empty list if the payment is unavailable
or does not match the filters.
id:
label: Id
user_request:
Expand Down
47 changes: 40 additions & 7 deletions api/lib/vpsadmin/api/resources/dataset.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,31 @@ class Dataset < HaveAPI::Resource
desc 'Manage datasets'
model ::Dataset

# Keep each endpoint's visible order aligned with its scoped ID anchor.
# A renamed dataset or concurrent write requires a fresh traversal.
module OrderedCursor
protected

def with_ordered_cursor(query, column:, descending: false)
table = query.klass.arel_table
value = table[column]
id = table[:id]
comparison = descending ? :lt : :gt
direction = descending ? :desc : :asc

scope = ar_with_pagination(query, check: true) do |rows, from_id|
anchor = query.where(id.eq(from_id)).pick(value)
error!('Invalid pagination cursor', {}, http_status: 400) if anchor.nil?

rows.where(value.public_send(comparison, anchor).or(
value.eq(anchor).and(id.public_send(comparison, from_id))
))
end

scope.reorder(value.public_send(direction), id.public_send(direction))
end
end

params(:id) do
id :id
end
Expand Down Expand Up @@ -37,6 +62,8 @@ class Dataset < HaveAPI::Resource
end

class Index < HaveAPI::Actions::Default::Index
include OrderedCursor

desc 'List datasets'

input do
Expand Down Expand Up @@ -81,7 +108,7 @@ def query
q = q.where(vps: input[:vps]) if input.has_key?(:vps)
q = q.to_depth(input[:to_depth]) if input[:to_depth]

q
q.distinct
end

def count
Expand All @@ -91,10 +118,11 @@ def count
def exec
ret = []

with_pagination(query.includes(
q = query.includes(
:dataset_properties,
dataset_in_pools: [{ pool: [{ node: [{ location: [:environment] }] }] }]
).order('full_name')).each do |ds|
)
with_ordered_cursor(q, column: :full_name).each do |ds|
ret << ds
end

Expand Down Expand Up @@ -489,6 +517,8 @@ class Snapshot < HaveAPI::Resource
end

class Index < HaveAPI::Actions::Default::Index
include OrderedCursor

desc 'List snapshots'

input do
Expand Down Expand Up @@ -516,7 +546,7 @@ def count
end

def exec
with_pagination(query.order('created_at'))
with_ordered_cursor(query, column: :created_at)
end
end

Expand Down Expand Up @@ -850,6 +880,8 @@ class PropertyHistory < HaveAPI::Resource
end

class Index < HaveAPI::Actions::Default::Index
include OrderedCursor

input do
datetime :from
datetime :to
Expand All @@ -876,8 +908,9 @@ def query
q = ::DatasetPropertyHistory.includes(:dataset_property).where(
dataset_property_id: props.pluck(:id)
)
q = q.where('created_at >= ?', input[:from]) if input[:from]
q = q.where('created_at <= ?', input[:to]) if input[:to]
created_at = ::DatasetPropertyHistory.arel_table[:created_at]
q = q.where(created_at.gteq(input[:from])) if input[:from]
q = q.where(created_at.lteq(input[:to])) if input[:to]
q
end

Expand All @@ -886,7 +919,7 @@ def count
end

def exec
with_pagination(query.order('created_at DESC'))
with_ordered_cursor(query, column: :created_at, descending: true)
end
end

Expand Down
25 changes: 18 additions & 7 deletions api/lib/vpsadmin/api/resources/ip_address_assignment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ class Index < HaveAPI::Actions::Default::Index
desc 'List IP address assignments'

input do
patch :from_id,
desc: 'Continue after the last assignment from the previous page. Keep the same filters and order. ' \
'Assignments are ordered by from_date and ID in the selected direction. ' \
'An unavailable or differently scoped cursor returns HTTP 400.'
use :all, include: %i[
ip_address
ip_addr
Expand Down Expand Up @@ -109,15 +113,22 @@ def count

def exec
q = query

case input[:order]
when 'newest'
q = q.order('ip_address_assignments.from_date DESC')
when 'oldest'
q = q.order('ip_address_assignments.from_date ASC')
ascending = input[:order] == 'oldest'
comparison = ascending ? '>' : '<'
direction = ascending ? 'ASC' : 'DESC'

q = ar_with_pagination(q, check: true) do |scope, from_id|
cursor_date = q.where(id: from_id).pick(:from_date)
error!('Invalid pagination cursor', {}, http_status: 400) unless cursor_date

scope.where(
"ip_address_assignments.from_date #{comparison} ? OR " \
"(ip_address_assignments.from_date = ? AND ip_address_assignments.id #{comparison} ?)",
cursor_date, cursor_date, from_id
)
end

with_pagination(q)
q.order("ip_address_assignments.from_date #{direction}, ip_address_assignments.id #{direction}")
end
end

Expand Down
4 changes: 2 additions & 2 deletions api/lib/vpsadmin/api/resources/vps_user_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class VpsUserData < HaveAPI::Resource
end

class Index < HaveAPI::Actions::Default::Index
desc 'List VPS user data'
desc 'List VPS user data ordered by ascending ID'

input do
use :all, include: %i[user format]
Expand Down Expand Up @@ -50,7 +50,7 @@ def count
end

def exec
with_pagination(query)
with_pagination(query.order(:id))
end
end

Expand Down
49 changes: 49 additions & 0 deletions api/spec/api/resources/dataset_property_history_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,55 @@ def create_history(property:, value:, created_at:)
end

describe 'Index' do
it 'rejects ordered cursor anchors excluded by name, date or dataset scope' do
excluded_name = create_history(property: quota_prop, value: 10, created_at: Time.utc(2024, 1, 1))
excluded_date = create_history(property: used_prop, value: 10, created_at: Time.utc(2024, 1, 1))
deleted = create_history(property: used_prop, value: 10, created_at: Time.utc(2024, 1, 1))
deleted_id = deleted.id
deleted.destroy!
foreign_ds, = create_dataset_with_pool!(user: other_user, pool: pool, name: 'cursor-foreign')
foreign = create_history(property: foreign_ds.dataset_properties.find_by!(name: 'used'),
value: 10, created_at: Time.utc(2024, 1, 3))

[2_147_483_647, deleted_id, excluded_name.id, foreign.id].each do |cursor|
as(user) { json_get property_history_path(dataset.id), property_history: { from_id: cursor, name: 'used' } }
expect_status(400)
expect(json['status']).to be(false)
end

as(user) do
json_get property_history_path(dataset.id), property_history: {
from_id: excluded_date.id, from: Time.utc(2024, 1, 2).iso8601
}
end
expect_status(400)
end

%i[member admin].each do |role|
it "traverses ordered cursor pages by descending timestamp and ID for #{role}" do
rows = [2, 5, 1, 5, 3, 4].map do |day|
create_history(property: used_prop, value: day, created_at: Time.utc(2024, 1, day))
end
expected = rows.sort_by { |row| [row.created_at, row.id] }.reverse.map(&:id)
actor = role == :admin ? SpecSeed.admin : user
collected = []
cursor = nil

4.times do
params = { limit: 2, name: 'used', from: Time.utc(2024, 1, 1).iso8601,
to: Time.utc(2024, 1, 5).iso8601 }
params[:from_id] = cursor if cursor
as(actor) { json_get property_history_path(dataset.id), property_history: params }
expect_status(200)
ids = history_rows.map { |row| row['id'] }
collected.concat(ids)
cursor = ids.last unless ids.empty?
end

expect(history_rows).to be_empty
expect(collected).to eq(expected)
end
end
it 'rejects unauthenticated access' do
json_get property_history_path(dataset.id)

Expand Down
55 changes: 55 additions & 0 deletions api/spec/api/resources/dataset_read_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,61 @@ def with_current_user(user)
end

describe 'Index' do
it 'rejects ordered cursor anchors outside the authorized filtered dataset query' do
[2_147_483_647, other_dataset.id].each do |cursor|
as(user) { json_get datasets_path, dataset: { from_id: cursor } }
expect_status(400)
expect(json['status']).to be(false)
end

as(user) do
json_get datasets_path, dataset: { from_id: user_dataset.id, role: 'hypervisor' }
end
expect_status(400)

as(SpecSeed.admin) do
json_get datasets_path, dataset: { from_id: other_dataset.id, user: user.id }
end
expect_status(400)
end

it 'deduplicates ordered cursor rows with multiple matching pools' do
second_pool = pool.dup
second_pool.assign_attributes(label: 'cursor-secondary', filesystem: 'cursor-secondary')
second_pool.save!
DatasetInPool.create!(dataset: user_dataset, pool: second_pool, confirmed: DatasetInPool.confirmed(:confirmed))

as(user) { json_get datasets_path, dataset: { limit: 2 } }
expect_status(200)
expect(datasets.map { |row| row['id'] }).to eq([user_dataset.id])
end

%i[member admin].each do |role|
it "traverses ordered cursor pages by name for #{role}" do
user_dataset.update!(name: 'zz-cursor-root')
rows = %w[z b d a c].map do |name|
create_dataset_with_pool!(user: user, pool: pool, name: "cursor-#{name}").first
end
expected = (rows + [user_dataset]).sort_by { |row| [row.full_name, row.id] }.map(&:id)
actor = role == :admin ? SpecSeed.admin : user
collected = []
cursor = nil

4.times do
params = { limit: 2 }
params[:user] = user.id if role == :admin
params[:from_id] = cursor if cursor
as(actor) { json_get datasets_path, dataset: params }
expect_status(200)
ids = datasets.map { |row| row['id'] }
collected.concat(ids)
cursor = ids.last unless ids.empty?
end

expect(datasets).to be_empty
expect(collected).to eq(expected)
end
end
it 'rejects unauthenticated access' do
json_get datasets_path

Expand Down
Loading
Loading