femslash-city/tests/site_tests.rb
Agnes 596fd85f2f
Some checks failed
CI / test (push) Has been cancelled
accidentally leaked secrets so had to nuke shit fuck
2026-08-19 17:29:54 -04:00

541 lines
18 KiB
Ruby
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# frozen_string_literal: true
require_relative './environment.rb'
def app
Sinatra::Application
end
describe Site do
describe 'username' do
it 'only passes valid hostnames for username' do
_(Site.valid_username?('|\|0p|E')).must_equal false
_(Site.valid_username?('nope-')).must_equal false
_(Site.valid_username?('-nope')).must_equal false
_(Site.valid_username?('do-pe')).must_equal true
_(Site.valid_username?('d')).must_equal true
_(Site.valid_username?('do')).must_equal true
end
end
describe 'child sites' do
it 'child sites do not require email validation' do
parent_site = Fabricate :site, email: 'parent@example.com', email_confirmed: false
child_site = Fabricate :site, parent_site_id: parent_site.id, email: nil
_(parent_site.parent?).must_equal true
_(child_site.parent?).must_equal false
# Parent site needs email validation, child site does not
_(parent_site.email_not_validated?).must_equal true
_(child_site.email_not_validated?).must_equal false
end
end
describe 'file type validation' do
it 'allows css files when libmagic misidentifies them as appleworks' do
_(Site.valid_file_mime_type_and_ext?('application/x-appleworks3', '.css')).must_equal true
end
it 'keeps the appleworks exception scoped to css files' do
_(Site.valid_file_mime_type_and_ext?('application/x-appleworks3', '.txt')).must_be_nil
end
end
describe 'tags' do
it 'should handle empty tags in comma-separated list' do
site = Fabricate.build :site, new_tags_string: 'art, , Seancore, microlables'
_(site.valid?).must_equal true
site.save
_(site.tags.map(&:name).sort).must_equal ['art', 'microlables', 'seancore']
end
it 'should handle multiple empty tags' do
site = Fabricate.build :site, new_tags_string: ', art, , , test, '
_(site.valid?).must_equal true
site.save
_(site.tags.map(&:name).sort).must_equal ['art', 'test']
end
end
describe 'email validation' do
it 'accepts valid email addresses' do
valid_emails = [
'user@example.com',
'test.email@domain.org',
'user+tag@example.com',
'user_name@domain.co.uk',
'user-name@example-domain.com',
'user@xn--1lqs71d.xn--wgv71a',
'a.very.long.email.address@long-domain-name.example.com'
]
valid_emails.each do |email|
site = Fabricate.build(:site, email: email)
site.valid?
email_errors = site.errors[:email] || []
format_errors = email_errors.select { |e| e.include?('valid email') }
_(format_errors).must_be_empty
end
end
it 'rejects invalid email addresses' do
invalid_emails = [
'user@domain.com💩💩💩',
'user@domain.com extra text',
'user @domain.com',
'user@domain',
'user@domain.',
'@domain.com',
'user@',
'plaintext',
]
invalid_emails.each do |email|
site = Fabricate.build(:site, email: email)
site.valid?
email_errors = site.errors[:email] || []
format_errors = email_errors.select { |e| e.include?('valid email') }
_(format_errors).wont_be_empty
end
end
it 'rejects emails that exceed byte length limit' do
long_email = 'user@example.com' + 'a' * 300 # Will exceed 254 byte limit
site = Fabricate.build(:site, email: long_email)
site.valid?
email_errors = site.errors[:email] || []
length_errors = email_errors.select { |e| e.include?('too long') }
_(length_errors).wont_be_empty
end
it 'rejects emails with unicode padding that exceed byte limit' do
unicode_email = 'user@example.com' + '' * 100 # Braille blank chars
_(unicode_email.bytesize).must_be :>, Site::MAX_EMAIL_LENGTH
site = Fabricate.build(:site, email: unicode_email)
site.valid?
email_errors = site.errors[:email] || []
length_errors = email_errors.select { |e| e.include?('too long') }
_(length_errors).wont_be_empty
end
it 'accepts emails just under the byte limit' do
under_limit_email = 'a' * (Site::MAX_EMAIL_LENGTH - 15) + '@example.com'
_(under_limit_email.bytesize).must_be :<=, Site::MAX_EMAIL_LENGTH
site = Fabricate.build(:site, email: under_limit_email)
site.valid?
email_errors = site.errors[:email] || []
length_errors = email_errors.select { |e| e.include?('too long') }
_(length_errors).must_be_empty
end
end
describe 'bitcoin address validation' do
it 'accepts valid bitcoin addresses' do
valid_addresses = [
'1BoatSLRHtKNngkdXEeobR76b53LETtpyT',
'3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy',
'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080',
'BC1TESTADDRESS'
]
valid_addresses.each do |address|
_(Site.valid_bitcoin_address?(address)).must_equal true
end
end
it 'rejects invalid bitcoin addresses' do
invalid_addresses = [
'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kg3g4ty',
'bcrt1q4d4v8g4qv9nwx7v6p5d3y8d6p8ygj5y0yl9q9',
'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn',
'notabitcoinaddress'
]
invalid_addresses.each do |address|
_(Site.valid_bitcoin_address?(address)).must_equal false
end
end
end
describe 'banning' do
it 'still makes files available' do
site = Fabricate :site
site.ban!
_(File.exist?(site.current_files_path('index.html'))).must_equal true
_(site.current_files_path('index.html')).must_equal File.join(Site::DELETED_SITES_ROOT, Site.sharding_dir(site.username), site.username, 'index.html')
end
it 'purges cache by default' do
site = Fabricate :site
PurgeCacheWorker.jobs.clear
$redis_proxy.del(PurgeCacheWorker::PURGE_STREAM_KEY)
expected_purges = site.site_files_dataset.count
site.ban!
PurgeCacheWorker.drain
stream_length = $redis_proxy.xlen(PurgeCacheWorker::PURGE_STREAM_KEY)
expected_stream_entries = expected_purges * (PurgeCacheWorker::FOLLOWUP_PURGE_DELAYS.length + 1)
_(stream_length).must_equal expected_stream_entries
end
it 'can skip cache purge' do
site = Fabricate :site
PurgeCacheWorker.jobs.clear
$redis_proxy.del(PurgeCacheWorker::PURGE_STREAM_KEY)
site.ban!(false)
PurgeCacheWorker.drain
stream_length = $redis_proxy.xlen(PurgeCacheWorker::PURGE_STREAM_KEY)
_(stream_length).must_equal 0
end
end
describe 'unban' do
it 'works' do
site = Fabricate :site
index_path = File.join site.base_files_path, 'index.html'
site.ban!
_(File.exist?(index_path)).must_equal false
site.unban!
site.reload
_(site.is_banned).must_equal false
_(site.banned_at).must_be_nil
_(site.blackbox_whitelisted).must_equal true
_(File.exist?(index_path)).must_equal true
end
end
describe 'directory create' do
it 'handles wacky pathnames' do
['/derp', '/derp/'].each do |path|
site = Fabricate :site
site_file_count = site.site_files_dataset.count
site.create_directory path
_(site.site_files.select {|s| s.path == '' || s.path == '.'}.length).must_equal 0
_(site.site_files.select {|s| s.path == path.gsub('/', '')}.first).wont_be_nil
_(site.site_files_dataset.count).must_equal site_file_count+1
end
end
it 'scrubs ../ from directory' do
site = Fabricate :site
site.create_directory '../../test'
_(site.site_files.select {|site_file| site_file.path =~ /\.\./}.length).must_equal 0
end
it 'blocks long directory create' do
site = Fabricate :site
long_path_string = 'a' * (SiteFile::FILE_PATH_CHARACTER_LIMIT + 1)
res = site.create_directory long_path_string
_(res).must_equal 'Directory path is too long.'
end
it 'blocks individual directory names that are too long' do
site = Fabricate :site
long_dir_name = 'a' * (SiteFile::FILE_NAME_CHARACTER_LIMIT + 1)
res = site.create_directory "somedir/#{long_dir_name}/anotherdir"
_(res).must_match /name is too long/i
end
it 'blocks directory names with backslashes' do
site = Fabricate :site
res = site.create_directory 'bad\\dir'
_(res).must_equal 'Directory path contains invalid characters.'
_(site.site_files_dataset.where(path: 'bad\\dir').count).must_equal 0
end
end
describe 'scrubbed_path' do
it 'preserves literal percent characters without URL decoding' do
site = Fabricate :site
test_paths = [
'100% awesome.jpg',
'derpking/70%off.png',
'50% sale.txt',
'discount%special.png',
'garfield is 100% sexy.jpg',
'path/with/100%valid.txt'
]
test_paths.each do |path|
scrubbed = site.scrubbed_path(path)
_(scrubbed).must_equal path # Should be exactly the same - no URL decoding
end
end
it 'still handles path traversal and other security issues' do
site = Fabricate :site
# Should still block path traversal
_(site.scrubbed_path('../../../etc/passwd')).must_equal 'etc/passwd'
_(site.scrubbed_path('../../test')).must_equal 'test'
# Should still remove empty components and dots
_(site.scrubbed_path('/./test/./file.txt')).must_equal 'test/file.txt'
_(site.scrubbed_path('test//file.txt')).must_equal 'test/file.txt'
# But percent characters should be preserved
_(site.scrubbed_path('test/70%off.png')).must_equal 'test/70%off.png'
end
it 'raises error for control characters' do
site = Fabricate :site
# Should still raise error for control characters (below ASCII 32)
_(proc { site.scrubbed_path("test\x00file.txt") }).must_raise ArgumentError
_(proc { site.scrubbed_path("test\x1Ffile.txt") }).must_raise ArgumentError
end
end
describe 'custom_max_space' do
it 'should use the custom max space if it is more' do
site = Fabricate :site
_(site.maximum_space).must_equal Site::PLAN_FEATURES[:free][:space]
site.custom_max_space = 10**9
site.save_changes
_(site.maximum_space).must_equal 10**9
end
end
describe 'can_email' do
it 'should fail if send_emails is false' do
site = Fabricate :site
_(site.can_email?).must_equal true
site.update send_emails: false
_(site.can_email?).must_equal false
_(site.can_email?(:send_comment_emails)).must_equal false
site.update send_emails: true
_(site.can_email?(:send_comment_emails)).must_equal true
site.update send_comment_emails: false
_(site.can_email?(:send_comment_emails)).must_equal false
end
end
describe 'title extraction from HTML' do
before do
@site = Fabricate :site
end
it 'extracts valid UTF-8 titles correctly' do
valid_html = '<html><head><title>Valid Title</title></head></html>'
tempfile = Tempfile.new(['test', '.html'])
tempfile.write(valid_html)
tempfile.rewind
original_title = @site.title
@site.send(:store_file, 'index.html', tempfile, {})
_(@site.title).must_equal 'Valid Title'
tempfile.unlink
end
it 'handles invalid UTF-8 bytes gracefully' do
invalid_html = "<html><head><title>Invalid\x83Title</title></head></html>"
tempfile = Tempfile.new(['test', '.html'])
tempfile.write(invalid_html)
tempfile.rewind
original_title = @site.title
@site.send(:store_file, 'index.html', tempfile, {})
_(@site.title).must_equal 'InvalidTitle'
tempfile.unlink
end
end
describe 'directory conflict handling' do
it 'rejects file upload when directory exists with same name' do
@site = Fabricate :site
@site.create_directory('test.html')
tmpfile = Tempfile.new
tmpfile.write 'test'
tmpfile.close
results = @site.store_files [{filename: 'test.html', tempfile: tmpfile}]
_(results[:error]).must_equal true
_(results[:error_type]).must_equal 'directory_exists'
_(results[:message]).must_include 'conflicts with an existing directory'
end
end
describe 'send_email' do
before do
EmailWorker.jobs.clear
@site = Fabricate :site
end
it 'works' do
@site.send_email(subject: 'Subject', body: 'Body')
_(EmailWorker.jobs.length).must_equal 1
args = EmailWorker.jobs.first['args'].first
_(args['from']).must_equal Site::FROM_EMAIL
_(args['to']).must_equal @site.email
_(args['subject']).must_equal 'Subject'
_(args['body']).must_equal 'Body'
end
it 'fails if send_emails is false' do
@site.update send_emails: false
@site.send_email(subject: 'Subject', body: 'Body')
end
end
describe 'plan_name' do
it 'should set to free for missing stripe_customer_id' do
site = Fabricate :site
_(site.reload.plan_type).must_equal 'free'
end
it 'should be free for no plan_type entry' do
site = Fabricate :site, stripe_customer_id: 'cust_derp'
_(site.plan_type).must_equal 'free'
end
it 'should match plan_type' do
%w{supporter free}.each do |plan_type|
site = Fabricate :site, plan_type: plan_type
_(site.plan_type).must_equal plan_type
end
site = Fabricate :site, plan_type: nil
_(site.plan_type).must_equal 'free'
end
end
describe 'suggestions' do
it 'should return suggestions for tags' do
site = Fabricate :site, new_tags_string: 'vegetables'
Site::SUGGESTIONS_LIMIT.times { Fabricate :site, new_tags_string: 'vegetables', site_changed: true }
_(site.suggestions.length).must_equal Site::SUGGESTIONS_LIMIT
site.suggestions.each do |suggestion|
_(suggestion.tags.first.name).must_equal 'vegetables'
end
site = Fabricate :site, new_tags_string: 'gardening'
(Site::SUGGESTIONS_LIMIT-5).times {
Fabricate :site, new_tags_string: 'gardening', views: Site::SUGGESTIONS_VIEWS_MIN, site_changed: true
}
_(site.suggestions.length).must_equal(Site::SUGGESTIONS_LIMIT)
end
end
describe 'purge_cache' do
before do
@site = Fabricate :site
PurgeCacheWorker.jobs.clear
end
it 'schedules follow-up purge jobs' do
before_purge = Time.now.to_f
@site.purge_cache '/file.png'
after_purge = Time.now.to_f
_(PurgeCacheWorker.jobs.length).must_equal PurgeCacheWorker::FOLLOWUP_PURGE_DELAYS.length + 1
_(purge_cache_immediate_jobs.length).must_equal 1
scheduled_jobs = purge_cache_scheduled_jobs.sort_by { |job| job['at'] }
_(scheduled_jobs.length).must_equal PurgeCacheWorker::FOLLOWUP_PURGE_DELAYS.length
scheduled_jobs.each do |job|
_(job['args']).must_equal [@site.username, '/file.png']
end
PurgeCacheWorker::FOLLOWUP_PURGE_DELAYS.each_with_index do |delay, index|
delay_seconds = delay.to_i
_(scheduled_jobs[index]['at']).must_be :>=, before_purge + delay_seconds
_(scheduled_jobs[index]['at']).must_be :<=, after_purge + delay_seconds
end
end
it 'works for /index.html' do
@site.purge_cache '/index.html'
_(purge_cache_immediate_jobs.length).must_equal 1
args = purge_cache_immediate_jobs.first['args']
_(args.first).must_equal @site.username
_(args.last).must_equal '/'
end
it 'works for /dir/index.html' do
@site.purge_cache '/dir/index.html'
_(purge_cache_immediate_jobs.length).must_equal 1
args = purge_cache_immediate_jobs.first['args']
_(args.first).must_equal @site.username
_(args.last).must_equal '/dir/'
end
it 'works for /test.html' do
@site.purge_cache '/test.html'
_(purge_cache_immediate_jobs.length).must_equal 1
args = purge_cache_immediate_jobs.first['args']
_(args.first).must_equal @site.username
_(args.last).must_equal '/test'
end
it 'works for /newdir/index.html' do
@site.purge_cache '/newdir/test.html'
_(purge_cache_immediate_jobs.length).must_equal 1
args = purge_cache_immediate_jobs.first['args']
_(args.first).must_equal @site.username
_(args.last).must_equal '/newdir/test'
end
it 'works for /file.png' do
@site.purge_cache '/file.png'
_(purge_cache_immediate_jobs.length).must_equal 1
args = purge_cache_immediate_jobs.first['args']
_(args.first).must_equal @site.username
_(args.last).must_equal '/file.png'
end
it 'works for /testdir/file.png' do
@site.purge_cache '/testdir/file.png'
_(purge_cache_immediate_jobs.length).must_equal 1
args = purge_cache_immediate_jobs.first['args']
_(args.first).must_equal @site.username
_(args.last).must_equal '/testdir/file.png'
end
it 'works for /notindex.html' do
@site.purge_cache '/notindex.html'
_(purge_cache_immediate_jobs.length).must_equal 1
args = purge_cache_immediate_jobs.first['args']
_(args.first).must_equal @site.username
_(args.last).must_equal '/notindex'
end
it 'works for index.html missing forward slash' do
@site.purge_cache 'index.html'
_(purge_cache_immediate_jobs.length).must_equal 1
args = purge_cache_immediate_jobs.first['args']
_(args.first).must_equal @site.username
_(args.last).must_equal '/'
end
it 'works for photo.png missing forward slash' do
@site.purge_cache 'photo.png'
_(purge_cache_immediate_jobs.length).must_equal 1
args = purge_cache_immediate_jobs.first['args']
_(args.first).must_equal @site.username
_(args.last).must_equal '/photo.png'
end
it 'works for testdir/photo.png missing forward slash' do
@site.purge_cache 'testdir/photo.png'
_(purge_cache_immediate_jobs.length).must_equal 1
args = purge_cache_immediate_jobs.first['args']
_(args.first).must_equal @site.username
_(args.last).must_equal '/testdir/photo.png'
end
end
end