266 行
9.9 KiB
Ruby
266 行
9.9 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe PostUrlSanitisationRule, type: :model do
|
|
before do
|
|
described_class.unscoped.delete_all
|
|
end
|
|
|
|
describe 'validations' do
|
|
it 'requires a source pattern' do
|
|
rule = described_class.new(priority: 10, source_pattern: nil, replacement: '')
|
|
|
|
expect(rule).to be_invalid
|
|
expect(rule.errors.details.fetch(:source_pattern)).to eq([{ error: :blank }])
|
|
end
|
|
|
|
it 'requires a unique source pattern' do
|
|
described_class.create!(priority: 10, source_pattern: 'source', replacement: 'first')
|
|
rule = described_class.new(
|
|
priority: 20,
|
|
source_pattern: 'source',
|
|
replacement: 'second'
|
|
)
|
|
|
|
expect(rule).to be_invalid
|
|
expect(rule.errors.details.fetch(:source_pattern)).to include(
|
|
error: :taken,
|
|
value: 'source'
|
|
)
|
|
end
|
|
|
|
it 'rejects an invalid regexp' do
|
|
rule = described_class.new(priority: 10, source_pattern: '[', replacement: '')
|
|
|
|
expect(rule).to be_invalid
|
|
expect(rule.errors[:source_pattern]).to include('変な正規表現だね〜(笑)')
|
|
end
|
|
end
|
|
|
|
describe '.sanitise' do
|
|
it 'applies each active rule once in priority order' do
|
|
described_class.create!(priority: 30, source_pattern: 'c', replacement: 'd')
|
|
described_class.create!(priority: 10, source_pattern: 'a', replacement: 'aa')
|
|
described_class.create!(priority: 20, source_pattern: 'aa', replacement: 'c')
|
|
discarded = described_class.create!(
|
|
priority: 5,
|
|
source_pattern: '.',
|
|
replacement: 'discarded'
|
|
)
|
|
discarded.discard!
|
|
|
|
expect(described_class.sanitise('a')).to eq('d')
|
|
end
|
|
|
|
it 'canonicalises the initial YouTube and Nico URL forms' do
|
|
create_initial_rules
|
|
|
|
urls = {
|
|
'https://youtu.be/abc123?si=share' => youtube_url('abc123'),
|
|
'https://www.youtube.com/live/abc123?t=10' => youtube_url('abc123'),
|
|
'https://youtube.com/shorts/abc123?feature=share' => youtube_url('abc123'),
|
|
'https://m.youtube.com/embed/abc123' => youtube_url('abc123'),
|
|
'https://youtube.com/watch?feature=share&v=abc123&t=10' => youtube_url('abc123'),
|
|
'https://nico.ms/sm123?from=share#fragment' => nico_url('sm123'),
|
|
'https://www.nicovideo.jp/watch/sm123?ref=share#fragment' => nico_url('sm123')
|
|
}
|
|
|
|
urls.each do |url, canonical_url|
|
|
expect(described_class.sanitise(url)).to eq(canonical_url)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '.apply!' do
|
|
it 'locks posts in ID order and updates through temporary URLs' do
|
|
first = create(:post, url: 'https://example.com/source/1')
|
|
second = create(:post, url: 'https://example.com/source/2')
|
|
create_source_rule
|
|
sql = capture_sql { described_class.apply! }
|
|
|
|
lock_sql = sql.find { _1.match?(/SELECT .*posts.*FOR UPDATE/i) }
|
|
expect(lock_sql).to match(/ORDER BY .*posts.*id.* ASC/i)
|
|
expect(sql.grep(/post-url-sanitising\.invalid/).size).to eq(2)
|
|
expect(first.reload.url).to eq('https://example.com/canonical/1')
|
|
expect(second.reload.url).to eq('https://example.com/canonical/2')
|
|
end
|
|
|
|
it 'loads and compiles rules only once' do
|
|
create(:post, url: 'https://example.com/source/1')
|
|
create(:post, url: 'https://example.com/source/2')
|
|
create_source_rule
|
|
|
|
expect(described_class).to receive(:rules).once.and_call_original
|
|
|
|
described_class.apply!
|
|
end
|
|
|
|
it 'does not change post versions, version_no, or updated_at' do
|
|
post = create(:post, url: 'https://example.com/source/1')
|
|
post.update_columns(version_no: 7, updated_at: 1.day.ago)
|
|
original_updated_at = post.reload.updated_at
|
|
create_source_rule
|
|
|
|
expect { described_class.apply! }.not_to change(PostVersion, :count)
|
|
|
|
post.reload
|
|
expect(post.url).to eq('https://example.com/canonical/1')
|
|
expect(post.version_no).to eq(7)
|
|
expect(post.updated_at).to eq(original_updated_at)
|
|
end
|
|
|
|
invalid_urls = {
|
|
'a blank URL' => '',
|
|
'an unparseable URL' => 'https://[',
|
|
'a non-HTTP URL' => 'ftp://example.com/file',
|
|
'an HTTP URL without a host' => 'https:/path'
|
|
}
|
|
|
|
invalid_urls.each do |description, sanitised_url|
|
|
it "rolls back every update when sanitisation produces #{ description }" do
|
|
valid_post = create(:post, url: 'https://example.com/source/1')
|
|
invalid_post = create(:post, url: 'https://example.com/invalid')
|
|
create_source_rule
|
|
described_class.create!(
|
|
priority: 20,
|
|
source_pattern: '\\Ahttps://example\\.com/invalid\\z',
|
|
replacement: sanitised_url
|
|
)
|
|
|
|
expect { described_class.apply! }
|
|
.to raise_error(described_class::InvalidUrlError) { |error|
|
|
expect(error.invalid_rows).to eq([
|
|
{ post_id: invalid_post.id,
|
|
original_url: 'https://example.com/invalid',
|
|
sanitised_url: }
|
|
])
|
|
}
|
|
expect(valid_post.reload.url).to eq('https://example.com/source/1')
|
|
expect(invalid_post.reload.url).to eq('https://example.com/invalid')
|
|
end
|
|
end
|
|
|
|
it 'rolls back every update when sanitisation produces a URL longer than 768 characters' do
|
|
path = 'a' * 375
|
|
original_url = "https://example.com/#{ path }"
|
|
sanitised_url = "https://example.com/#{ path }#{ path }"
|
|
valid_post = create(:post, url: 'https://example.com/source/1')
|
|
invalid_post = create(:post, url: original_url)
|
|
create_source_rule
|
|
described_class.create!(
|
|
priority: 20,
|
|
source_pattern: '\\Ahttps://example\\.com/(a+)\\z',
|
|
replacement: 'https://example.com/\\1\\1'
|
|
)
|
|
|
|
expect { described_class.apply! }
|
|
.to raise_error(described_class::InvalidUrlError) { |error|
|
|
expect(error.invalid_rows).to eq([
|
|
{ post_id: invalid_post.id,
|
|
original_url:,
|
|
sanitised_url: }
|
|
])
|
|
}
|
|
expect(valid_post.reload.url).to eq('https://example.com/source/1')
|
|
expect(invalid_post.reload.url).to eq(original_url)
|
|
end
|
|
|
|
it 'rejects sanitised URL collisions case-insensitively without changing posts' do
|
|
source = create(:post, url: 'https://example.com/source/Foo')
|
|
canonical = create(:post, url: 'https://example.com/canonical/foo')
|
|
create_source_rule
|
|
|
|
expect { described_class.apply! }
|
|
.to raise_error(described_class::UrlConflictError) { |error|
|
|
expect(error.conflicts).to contain_exactly(
|
|
{ url: 'https://example.com/canonical/Foo',
|
|
post_id: source.id,
|
|
original_url: 'https://example.com/source/Foo' },
|
|
{ url: 'https://example.com/canonical/foo',
|
|
post_id: canonical.id,
|
|
original_url: 'https://example.com/canonical/foo' }
|
|
)
|
|
}
|
|
expect(source.reload.url).to eq('https://example.com/source/Foo')
|
|
expect(canonical.reload.url).to eq('https://example.com/canonical/foo')
|
|
expect(Post.count).to eq(2)
|
|
end
|
|
|
|
it 'converts a persisted URL constraint race into UrlConflictError' do
|
|
post = create(:post, url: 'https://example.com/source/1')
|
|
create_source_rule
|
|
conflict = { url: 'https://example.com/canonical/1',
|
|
post_id: post.id,
|
|
original_url: post.url }
|
|
database_error = ActiveRecord::RecordNotUnique.new('duplicate URL')
|
|
allow_any_instance_of(ActiveRecord::Relation)
|
|
.to receive(:update_all).and_raise(database_error)
|
|
allow(described_class).to receive(:build_persisted_conflicts).and_return([conflict])
|
|
|
|
expect { described_class.apply! }
|
|
.to raise_error(described_class::UrlConflictError) { |error|
|
|
expect(error.conflicts).to eq([conflict])
|
|
expect(error.cause).to equal(database_error)
|
|
}
|
|
expect(post.reload.url).to eq('https://example.com/source/1')
|
|
end
|
|
|
|
it 're-raises an unidentified RecordNotUnique error' do
|
|
post = create(:post, url: 'https://example.com/source/1')
|
|
create_source_rule
|
|
database_error = ActiveRecord::RecordNotUnique.new('another unique constraint')
|
|
allow_any_instance_of(ActiveRecord::Relation)
|
|
.to receive(:update_all).and_raise(database_error)
|
|
allow(described_class).to receive(:build_persisted_conflicts).and_return([])
|
|
|
|
expect { described_class.apply! }.to raise_error(database_error)
|
|
expect(post.reload.url).to eq('https://example.com/source/1')
|
|
end
|
|
end
|
|
|
|
def capture_sql
|
|
statements = []
|
|
subscriber = lambda do |_name, _start, _finish, _id, payload|
|
|
binds = payload.fetch(:binds, []).map { _1.value_for_database.to_s }
|
|
statements << ([payload.fetch(:sql)] + binds).join(' ')
|
|
end
|
|
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
|
|
statements
|
|
end
|
|
|
|
def create_source_rule
|
|
described_class.create!(
|
|
priority: 10,
|
|
source_pattern: '\\Ahttps://example\\.com/source/(.+)\\z',
|
|
replacement: 'https://example.com/canonical/\\1'
|
|
)
|
|
end
|
|
|
|
def create_initial_rules
|
|
rules = [
|
|
['\\Ahttps?://youtu\\.be/([^/?#]+)(?:[?#].*)?\\z', youtube_url('\\1')],
|
|
['\\Ahttps?://(?:www\\.|m\\.)?youtube\\.com/live/([^/?#]+)(?:[?#].*)?\\z',
|
|
youtube_url('\\1')],
|
|
['\\Ahttps?://(?:www\\.|m\\.)?youtube\\.com/shorts/([^/?#]+)(?:[?#].*)?\\z',
|
|
youtube_url('\\1')],
|
|
['\\Ahttps?://(?:www\\.|m\\.)?youtube\\.com/embed/([^/?#]+)(?:[?#].*)?\\z',
|
|
youtube_url('\\1')],
|
|
['\\Ahttps?://(?:www\\.|m\\.)?youtube\\.com/watch\\?(?:[^#&]+&)*' \
|
|
'v=([^&#]+)(?:[&#].*)?\\z', youtube_url('\\1')],
|
|
['\\Ahttps?://nico\\.ms/([^/?#]+)(?:[?#].*)?\\z', nico_url('\\1')],
|
|
['\\Ahttps?://(?:www\\.)?nicovideo\\.jp/watch/([^?#/]+)(?:[?#].*)?\\z',
|
|
nico_url('\\1')]
|
|
]
|
|
rules.each_with_index do |(source_pattern, replacement), index|
|
|
described_class.create!(
|
|
priority: (index + 1) * 10,
|
|
source_pattern:,
|
|
replacement:
|
|
)
|
|
end
|
|
end
|
|
|
|
def youtube_url(video_id) = "https://www.youtube.com/watch?v=#{ video_id }"
|
|
|
|
def nico_url(video_id) = "https://www.nicovideo.jp/watch/#{ video_id }"
|
|
end
|