fix(sync): protect follower sync tokens
Store read-only receiver tokens as keyed digests and expose only sync_auth_configured in owner responses. Keep leader push tokens write-only in API responses because outbound sync still needs to send them. Refs TUR-84
This commit is contained in:
parent
6c01343d3a
commit
2b63e61475
|
|
@ -319,9 +319,7 @@ class TournamentsController < ApplicationController
|
||||||
token = request.authorization.to_s.delete_prefix('Bearer ').presence || request.headers['X-Tournament-Sync-Token'].to_s
|
token = request.authorization.to_s.delete_prefix('Bearer ').presence || request.headers['X-Tournament-Sync-Token'].to_s
|
||||||
return render json: { error: 'Missing sync token' }, status: :unauthorized if token.blank?
|
return render json: { error: 'Missing sync token' }, status: :unauthorized if token.blank?
|
||||||
|
|
||||||
matches = token.bytesize == @tournament.sync_auth_token.to_s.bytesize &&
|
return if @tournament.sync_token_matches?(token)
|
||||||
ActiveSupport::SecurityUtils.secure_compare(token, @tournament.sync_auth_token.to_s)
|
|
||||||
return if matches
|
|
||||||
|
|
||||||
render json: { error: 'Invalid sync token' }, status: :unauthorized
|
render json: { error: 'Invalid sync token' }, status: :unauthorized
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require 'securerandom'
|
require 'securerandom'
|
||||||
|
require 'openssl'
|
||||||
|
|
||||||
class Tournament < ApplicationRecord
|
class Tournament < ApplicationRecord
|
||||||
TIMER_MODES = %w[countdown countup].freeze
|
TIMER_MODES = %w[countdown countup].freeze
|
||||||
|
|
@ -26,6 +27,7 @@ class Tournament < ApplicationRecord
|
||||||
after_initialize :generate_code
|
after_initialize :generate_code
|
||||||
after_create_commit :ensure_default_beamer!
|
after_create_commit :ensure_default_beamer!
|
||||||
before_validation :normalize_timer_reason
|
before_validation :normalize_timer_reason
|
||||||
|
before_validation :protect_follower_sync_token
|
||||||
before_validation :clear_follower_sync_token_when_disabling_read_only_mode
|
before_validation :clear_follower_sync_token_when_disabling_read_only_mode
|
||||||
after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state?
|
after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state?
|
||||||
|
|
||||||
|
|
@ -50,7 +52,27 @@ class Tournament < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_accepts_push?
|
def sync_accepts_push?
|
||||||
read_only_mode? && sync_auth_token.present?
|
read_only_mode? && sync_auth_configured?
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_auth_configured?
|
||||||
|
sync_auth_token.present? || sync_auth_token_digest.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_token_matches?(token)
|
||||||
|
return false if token.blank?
|
||||||
|
|
||||||
|
if sync_auth_token_digest.present?
|
||||||
|
expected_digest = self.class.sync_auth_token_digest(token)
|
||||||
|
return ActiveSupport::SecurityUtils.secure_compare(expected_digest, sync_auth_token_digest)
|
||||||
|
end
|
||||||
|
|
||||||
|
token.bytesize == sync_auth_token.to_s.bytesize &&
|
||||||
|
ActiveSupport::SecurityUtils.secure_compare(token, sync_auth_token.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.sync_auth_token_digest(token)
|
||||||
|
OpenSSL::HMAC.hexdigest('SHA256', Rails.application.secret_key_base, token.to_s)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
@ -80,15 +102,15 @@ class Tournament < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_configuration_blank?
|
def sync_configuration_blank?
|
||||||
sync_target_url.blank? && sync_auth_token.blank?
|
sync_target_url.blank? && !sync_auth_configured?
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_configuration_complete?
|
def sync_configuration_complete?
|
||||||
sync_target_url.present? && sync_auth_token.present?
|
sync_target_url.present? && sync_auth_configured?
|
||||||
end
|
end
|
||||||
|
|
||||||
def follower_sync_token_only?
|
def follower_sync_token_only?
|
||||||
read_only_mode? && sync_auth_token.present? && sync_target_url.blank?
|
read_only_mode? && sync_auth_configured? && sync_target_url.blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_default_beamer!
|
def ensure_default_beamer!
|
||||||
|
|
@ -100,11 +122,24 @@ class Tournament < ApplicationRecord
|
||||||
|
|
||||||
def clear_follower_sync_token_when_disabling_read_only_mode
|
def clear_follower_sync_token_when_disabling_read_only_mode
|
||||||
return if read_only_mode?
|
return if read_only_mode?
|
||||||
|
|
||||||
|
self.sync_auth_token_digest = nil
|
||||||
return if sync_target_url.present?
|
return if sync_target_url.present?
|
||||||
|
|
||||||
self.sync_auth_token = nil
|
self.sync_auth_token = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def protect_follower_sync_token
|
||||||
|
return unless read_only_mode?
|
||||||
|
|
||||||
|
if sync_auth_token.present?
|
||||||
|
self.sync_auth_token_digest = self.class.sync_auth_token_digest(sync_auth_token)
|
||||||
|
self.sync_auth_token = nil
|
||||||
|
elsif will_save_change_to_sync_auth_token? && sync_auth_token.blank?
|
||||||
|
self.sync_auth_token_digest = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def normalize_timer_reason
|
def normalize_timer_reason
|
||||||
self.timer_reason = timer_reason.presence
|
self.timer_reason = timer_reason.presence
|
||||||
self.timer_reason_text = timer_reason_text&.strip&.presence
|
self.timer_reason_text = timer_reason_text&.strip&.presence
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ class TournamentSerializer < SimpleTournamentSerializer
|
||||||
:instant_finalists_amount, :intermediate_round_participants_amount
|
:instant_finalists_amount, :intermediate_round_participants_amount
|
||||||
attribute :read_only_mode, if: :sync_metadata_visible?
|
attribute :read_only_mode, if: :sync_metadata_visible?
|
||||||
attribute :sync_target_url, if: :sync_metadata_visible?
|
attribute :sync_target_url, if: :sync_metadata_visible?
|
||||||
|
attribute :sync_auth_configured, if: :sync_metadata_visible?
|
||||||
attribute :sync_last_push_error, if: :sync_metadata_visible?
|
attribute :sync_last_push_error, if: :sync_metadata_visible?
|
||||||
# NEVER expose sync_auth_token anywhere - it should only ever be written to or checked against
|
# NEVER expose sync_auth_token anywhere - it should only ever be written to or checked against
|
||||||
|
|
||||||
|
|
@ -41,4 +42,8 @@ class TournamentSerializer < SimpleTournamentSerializer
|
||||||
def sync_metadata_visible?
|
def sync_metadata_visible?
|
||||||
scope.present? && scope == object.owner
|
scope.present? && scope == object.owner
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sync_auth_configured
|
||||||
|
object.sync_auth_configured?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class TournamentSyncSchema
|
||||||
read_only_mode
|
read_only_mode
|
||||||
sync_target_url
|
sync_target_url
|
||||||
sync_auth_token
|
sync_auth_token
|
||||||
|
sync_auth_token_digest
|
||||||
sync_source_tournament_id
|
sync_source_tournament_id
|
||||||
sync_last_pushed_at
|
sync_last_pushed_at
|
||||||
sync_last_push_error
|
sync_last_push_error
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'openssl'
|
||||||
|
|
||||||
|
class AddSyncAuthTokenDigestToTournaments < ActiveRecord::Migration[7.0]
|
||||||
|
class MigrationTournament < ActiveRecord::Base
|
||||||
|
self.table_name = 'tournaments'
|
||||||
|
end
|
||||||
|
|
||||||
|
def up
|
||||||
|
unless column_exists?(:tournaments, :sync_auth_token_digest)
|
||||||
|
add_column :tournaments, :sync_auth_token_digest, :string
|
||||||
|
end
|
||||||
|
|
||||||
|
MigrationTournament.reset_column_information
|
||||||
|
MigrationTournament
|
||||||
|
.where(read_only_mode: true)
|
||||||
|
.where.not(sync_auth_token: [nil, ''])
|
||||||
|
.find_each do |tournament|
|
||||||
|
tournament.update!(
|
||||||
|
sync_auth_token: nil,
|
||||||
|
sync_auth_token_digest: sync_auth_token_digest(tournament.sync_auth_token)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
remove_column :tournaments, :sync_auth_token_digest if column_exists?(:tournaments, :sync_auth_token_digest)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def sync_auth_token_digest(token)
|
||||||
|
OpenSSL::HMAC.hexdigest('SHA256', Rails.application.secret_key_base, token.to_s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2026_04_28_120000) do
|
ActiveRecord::Schema[8.1].define(version: 2026_04_30_120000) do
|
||||||
create_table "beamers", force: :cascade do |t|
|
create_table "beamers", force: :cascade do |t|
|
||||||
t.json "config", default: {}, null: false
|
t.json "config", default: {}, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
|
|
@ -158,15 +158,16 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_28_120000) do
|
||||||
t.boolean "public", default: true
|
t.boolean "public", default: true
|
||||||
t.boolean "read_only_mode", default: false, null: false
|
t.boolean "read_only_mode", default: false, null: false
|
||||||
t.string "sync_auth_token"
|
t.string "sync_auth_token"
|
||||||
|
t.string "sync_auth_token_digest"
|
||||||
t.datetime "sync_last_imported_snapshot_at"
|
t.datetime "sync_last_imported_snapshot_at"
|
||||||
t.string "sync_last_push_error"
|
t.string "sync_last_push_error"
|
||||||
t.datetime "sync_last_pushed_at"
|
t.datetime "sync_last_pushed_at"
|
||||||
t.integer "sync_source_tournament_id"
|
t.integer "sync_source_tournament_id"
|
||||||
t.string "sync_target_url"
|
t.string "sync_target_url"
|
||||||
t.string "timer_mode"
|
t.string "timer_mode"
|
||||||
t.datetime "timestamp"
|
|
||||||
t.string "timer_reason"
|
t.string "timer_reason"
|
||||||
t.text "timer_reason_text"
|
t.text "timer_reason_text"
|
||||||
|
t.datetime "timestamp"
|
||||||
t.datetime "updated_at", precision: nil, null: false
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
t.integer "user_id", null: false
|
t.integer "user_id", null: false
|
||||||
t.index ["code"], name: "index_tournaments_on_code", unique: true
|
t.index ["code"], name: "index_tournaments_on_code", unique: true
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ Create empty tournament with:
|
||||||
- `sync_auth_token: <shared secret>`
|
- `sync_auth_token: <shared secret>`
|
||||||
|
|
||||||
Do not send `teams` payload for follower. Backend allows empty read only follower tournament creation.
|
Do not send `teams` payload for follower. Backend allows empty read only follower tournament creation.
|
||||||
|
The token is write-only. Backend responses never return it; owner responses only expose `sync_auth_configured`.
|
||||||
|
|
||||||
### 2. Create normal leader tournament on local backend
|
### 2. Create normal leader tournament on local backend
|
||||||
|
|
||||||
|
|
@ -178,9 +179,9 @@ Owner-facing frontend can expose:
|
||||||
|
|
||||||
Recommended owner UX:
|
Recommended owner UX:
|
||||||
|
|
||||||
1. create remote follower first
|
1. generate a shared token outside the backend
|
||||||
2. copy remote `sync_auth_token`
|
2. create remote follower with that `sync_auth_token`
|
||||||
3. paste remote `sync_state` URL into leader
|
3. paste same token and remote `sync_state` URL into leader
|
||||||
4. save leader sync config
|
4. save leader sync config
|
||||||
5. show last push time / error state
|
5. show last push time / error state
|
||||||
6. allow explicit follower takeover by disabling `read_only_mode`
|
6. allow explicit follower takeover by disabling `read_only_mode`
|
||||||
|
|
|
||||||
|
|
@ -150,9 +150,11 @@ RSpec.describe TournamentsController, type: :controller do
|
||||||
json = deserialize_response(response)
|
json = deserialize_response(response)
|
||||||
expect(json[:read_only_mode]).to eq(true)
|
expect(json[:read_only_mode]).to eq(true)
|
||||||
expect(json[:sync_target_url]).to eq('https://remote.example.com/tournaments/1/sync_state')
|
expect(json[:sync_target_url]).to eq('https://remote.example.com/tournaments/1/sync_state')
|
||||||
|
expect(json[:sync_auth_configured]).to eq(true)
|
||||||
expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601)
|
expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601)
|
||||||
expect(json[:sync_last_push_error]).to eq('push failed')
|
expect(json[:sync_last_push_error]).to eq('push failed')
|
||||||
expect(json).not_to have_key(:sync_auth_token)
|
expect(json).not_to have_key(:sync_auth_token)
|
||||||
|
expect(json).not_to have_key(:sync_auth_token_digest)
|
||||||
expect(json).to eq(TournamentSerializer.new(@tournament, scope: @tournament.owner).as_json)
|
expect(json).to eq(TournamentSerializer.new(@tournament, scope: @tournament.owner).as_json)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -389,8 +391,12 @@ RSpec.describe TournamentsController, type: :controller do
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
tournament = Tournament.find(deserialize_response(response)[:id])
|
tournament = Tournament.find(deserialize_response(response)[:id])
|
||||||
expect(tournament.read_only_mode?).to eq(true)
|
expect(tournament.read_only_mode?).to eq(true)
|
||||||
|
expect(tournament.sync_auth_token).to be_nil
|
||||||
|
expect(tournament.sync_auth_token_digest).to be_present
|
||||||
expect(tournament.teams).to be_empty
|
expect(tournament.teams).to be_empty
|
||||||
expect(tournament.stages).to be_empty
|
expect(tournament.stages).to be_empty
|
||||||
|
expect(deserialize_response(response)).not_to have_key(:sync_auth_token)
|
||||||
|
expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -602,6 +608,9 @@ RSpec.describe TournamentsController, type: :controller do
|
||||||
expect(@tournament.read_only_mode?).to eq(false)
|
expect(@tournament.read_only_mode?).to eq(false)
|
||||||
expect(@tournament.sync_target_url).to include('/sync_state')
|
expect(@tournament.sync_target_url).to include('/sync_state')
|
||||||
expect(@tournament.sync_auth_token).to eq('new-token')
|
expect(@tournament.sync_auth_token).to eq('new-token')
|
||||||
|
expect(@tournament.sync_auth_token_digest).to be_nil
|
||||||
|
expect(deserialize_response(response)).not_to have_key(:sync_auth_token)
|
||||||
|
expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'allows follower takeover when only read_only_mode is disabled' do
|
it 'allows follower takeover when only read_only_mode is disabled' do
|
||||||
|
|
@ -618,6 +627,7 @@ RSpec.describe TournamentsController, type: :controller do
|
||||||
expect(@tournament.read_only_mode?).to eq(false)
|
expect(@tournament.read_only_mode?).to eq(false)
|
||||||
expect(@tournament.sync_target_url).to be_blank
|
expect(@tournament.sync_target_url).to be_blank
|
||||||
expect(@tournament.sync_auth_token).to be_blank
|
expect(@tournament.sync_auth_token).to be_blank
|
||||||
|
expect(@tournament.sync_auth_token_digest).to be_blank
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'blocks normal updates while tournament is read only' do
|
it 'blocks normal updates while tournament is read only' do
|
||||||
|
|
@ -818,6 +828,8 @@ RSpec.describe TournamentsController, type: :controller do
|
||||||
expect(@tournament.sync_source_tournament_id).to eq(123)
|
expect(@tournament.sync_source_tournament_id).to eq(123)
|
||||||
expect(@tournament.name).to eq('Synced Tournament')
|
expect(@tournament.name).to eq('Synced Tournament')
|
||||||
expect(@tournament.teams.pluck(:name)).to eq(['Alpha'])
|
expect(@tournament.teams.pluck(:name)).to eq(['Alpha'])
|
||||||
|
expect(deserialize_response(response)).not_to have_key(:sync_auth_token)
|
||||||
|
expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'rejects invalid tokens' do
|
it 'rejects invalid tokens' do
|
||||||
|
|
@ -857,6 +869,9 @@ RSpec.describe TournamentsController, type: :controller do
|
||||||
expect(json[:id]).to eq(@tournament.id)
|
expect(json[:id]).to eq(@tournament.id)
|
||||||
expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601)
|
expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601)
|
||||||
expect(json[:sync_last_push_error]).to be_nil
|
expect(json[:sync_last_push_error]).to be_nil
|
||||||
|
expect(json[:sync_auth_configured]).to eq(true)
|
||||||
|
expect(json).not_to have_key(:sync_auth_token)
|
||||||
|
expect(json).not_to have_key(:sync_auth_token_digest)
|
||||||
expect(TournamentSyncPusher).to have_received(:push!).with(@tournament)
|
expect(TournamentSyncPusher).to have_received(:push!).with(@tournament)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
|
||||||
expect(test_sync[:status]).to eq(200)
|
expect(test_sync[:status]).to eq(200)
|
||||||
expect(test_sync.dig(:json, :sync_last_pushed_at)).not_to be_nil
|
expect(test_sync.dig(:json, :sync_last_pushed_at)).not_to be_nil
|
||||||
expect(test_sync.dig(:json, :sync_last_push_error)).to be_nil
|
expect(test_sync.dig(:json, :sync_last_push_error)).to be_nil
|
||||||
|
expect(test_sync.dig(:json, :sync_auth_configured)).to eq(true)
|
||||||
|
expect_no_sync_token_exposed!(test_sync, sync_token)
|
||||||
|
|
||||||
after_leader = fetch_tournament(leader.fetch(:id))
|
after_leader = fetch_tournament(leader.fetch(:id))
|
||||||
after_follower = fetch_tournament(follower.fetch(:id))
|
after_follower = fetch_tournament(follower.fetch(:id))
|
||||||
|
|
@ -54,6 +56,41 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
|
||||||
.to match_array(after_leader.fetch(:teams).map { |team| team.fetch(:name) })
|
.to match_array(after_leader.fetch(:teams).map { |team| team.fetch(:name) })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'never exposes sync auth token after create, update, show, or sync test' do
|
||||||
|
follower_create = client.post('/tournaments', body: {
|
||||||
|
name: "Protected Follower #{SecureRandom.hex(3)}",
|
||||||
|
description: 'Follower token should stay write only',
|
||||||
|
public: true,
|
||||||
|
read_only_mode: true,
|
||||||
|
sync_auth_token: sync_token
|
||||||
|
})
|
||||||
|
expect(follower_create[:status]).to eq(201)
|
||||||
|
expect_no_sync_token_exposed!(follower_create, sync_token)
|
||||||
|
|
||||||
|
follower_id = follower_create.dig(:json, :id)
|
||||||
|
follower_owner_read = client.get("/tournaments/#{follower_id}")
|
||||||
|
expect(follower_owner_read[:status]).to eq(200)
|
||||||
|
expect(follower_owner_read.dig(:json, :sync_auth_configured)).to eq(true)
|
||||||
|
expect_no_sync_token_exposed!(follower_owner_read, sync_token)
|
||||||
|
|
||||||
|
follower_public_read = anonymous_client.get("/tournaments/#{follower_id}")
|
||||||
|
expect(follower_public_read[:status]).to eq(200)
|
||||||
|
expect_no_sync_token_exposed!(follower_public_read, sync_token)
|
||||||
|
|
||||||
|
leader = create_group_stage_tournament(name_prefix: 'Protected Leader')
|
||||||
|
configure_sync = client.patch("/tournaments/#{leader.fetch(:id)}", body: {
|
||||||
|
sync_target_url: "#{base_url}/tournaments/#{follower_id}/sync_state",
|
||||||
|
sync_auth_token: sync_token
|
||||||
|
})
|
||||||
|
expect(configure_sync[:status]).to eq(200)
|
||||||
|
expect(configure_sync.dig(:json, :sync_auth_configured)).to eq(true)
|
||||||
|
expect_no_sync_token_exposed!(configure_sync, sync_token)
|
||||||
|
|
||||||
|
test_sync = client.post("/tournaments/#{leader.fetch(:id)}/test_sync")
|
||||||
|
expect(test_sync[:status]).to eq(200)
|
||||||
|
expect_no_sync_token_exposed!(test_sync, sync_token)
|
||||||
|
end
|
||||||
|
|
||||||
it 'syncs team action list changes made through item id route and business key route' do
|
it 'syncs team action list changes made through item id route and business key route' do
|
||||||
follower = create_follower_tournament(name_prefix: 'Sync Action Follower')
|
follower = create_follower_tournament(name_prefix: 'Sync Action Follower')
|
||||||
leader = create_group_stage_tournament(
|
leader = create_group_stage_tournament(
|
||||||
|
|
@ -170,6 +207,14 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
|
||||||
response.fetch(:json)
|
response.fetch(:json)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def expect_no_sync_token_exposed!(response, token)
|
||||||
|
serialized_json = response.fetch(:json).inspect
|
||||||
|
|
||||||
|
expect(serialized_json).not_to include('sync_auth_token')
|
||||||
|
expect(serialized_json).not_to include('sync_auth_token_digest')
|
||||||
|
expect(serialized_json).not_to include(token)
|
||||||
|
end
|
||||||
|
|
||||||
def finish_group_stage_and_create_playoffs!(tournament_id)
|
def finish_group_stage_and_create_playoffs!(tournament_id)
|
||||||
tournament = fetch_tournament(tournament_id)
|
tournament = fetch_tournament(tournament_id)
|
||||||
group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
|
group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,40 @@ RSpec.describe Tournament, type: :model do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'sync auth token protection' do
|
||||||
|
it 'stores follower receiver tokens only as a digest' do
|
||||||
|
tournament = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret')
|
||||||
|
|
||||||
|
expect(tournament.sync_auth_token).to be_nil
|
||||||
|
expect(tournament.sync_auth_token_digest).to be_present
|
||||||
|
expect(tournament.sync_token_matches?('shared-secret')).to eq(true)
|
||||||
|
expect(tournament.sync_token_matches?('wrong-secret')).to eq(false)
|
||||||
|
expect(tournament.sync_accepts_push?).to eq(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps leader push token available for outbound sync' do
|
||||||
|
tournament = create(
|
||||||
|
:tournament,
|
||||||
|
sync_target_url: 'https://remote.example.com/tournaments/1/sync_state',
|
||||||
|
sync_auth_token: 'shared-secret'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(tournament.sync_auth_token).to eq('shared-secret')
|
||||||
|
expect(tournament.sync_auth_token_digest).to be_nil
|
||||||
|
expect(tournament.sync_push_enabled?).to eq(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'clears follower token digest on takeover' do
|
||||||
|
tournament = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret')
|
||||||
|
|
||||||
|
tournament.update!(read_only_mode: false)
|
||||||
|
|
||||||
|
expect(tournament.sync_auth_token).to be_nil
|
||||||
|
expect(tournament.sync_auth_token_digest).to be_nil
|
||||||
|
expect(tournament.sync_auth_configured?).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe '#matches' do
|
describe '#matches' do
|
||||||
context 'group stage tournament' do
|
context 'group stage tournament' do
|
||||||
before do
|
before do
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue