# frozen_string_literal: true require_relative './environment.rb' require 'rack/test' module PayPalRecurringTestHelpers def configure_test_paypal @paypal_config = { sandbox: PayPalRecurring.sandbox, username: PayPalRecurring.username, password: PayPalRecurring.password, signature: PayPalRecurring.signature } PayPalRecurring.configure do |config| config.sandbox = false config.username = 'api-user' config.password = 'api-pass' config.signature = 'api-signature' end end def restore_paypal_config PayPalRecurring.configure do |config| config.sandbox = @paypal_config[:sandbox] config.username = @paypal_config[:username] config.password = @paypal_config[:password] config.signature = @paypal_config[:signature] end end def stub_paypal_response(expected_params, body) stub_request(:post, PayPalRecurring.api_endpoint).with do |request| params = URI.decode_www_form(request.body).to_h expected_params.all? { |name, value| params[name] == value } end.to_return(status: 200, body: body) end def recurring_params { ipn_url: 'https://neocities.org/webhooks/paypal', description: 'Neocities Supporter - Monthly', amount: '5.0', currency: 'USD' } end def authorization_params recurring_params.merge( return_url: 'https://neocities.org/supporter/paypal/return', cancel_url: 'https://neocities.org/supporter' ) end end describe PayPalRecurring do include PayPalRecurringTestHelpers before do configure_test_paypal end after do restore_paypal_config end it 'starts an express checkout recurring billing agreement' do stub_paypal_response({ 'METHOD' => 'SetExpressCheckout', 'USER' => 'api-user', 'PWD' => 'api-pass', 'SIGNATURE' => 'api-signature', 'VERSION' => PayPalRecurring::API_VERSION, 'PAYMENTREQUEST_0_AMT' => '5.0', 'AMT' => '5.0', 'PAYMENTREQUEST_0_CURRENCYCODE' => 'USD', 'CURRENCYCODE' => 'USD', 'DESC' => 'Neocities Supporter - Monthly', 'L_BILLINGAGREEMENTDESCRIPTION0' => 'Neocities Supporter - Monthly', 'PAYMENTREQUEST_0_NOTIFYURL' => 'https://neocities.org/webhooks/paypal', 'NOTIFYURL' => 'https://neocities.org/webhooks/paypal', 'RETURNURL' => 'https://neocities.org/supporter/paypal/return', 'CANCELURL' => 'https://neocities.org/supporter', 'PAYMENTREQUEST_0_PAYMENTACTION' => 'Authorization', 'NOSHIPPING' => '1', 'L_BILLINGTYPE0' => 'RecurringPayments' }, 'ACK=Success&TOKEN=EC-123') response = PayPalRecurring.new(authorization_params).checkout _(response.valid?).must_equal true _(response.token).must_equal 'EC-123' _(response.checkout_url).must_equal 'https://www.paypal.com/cgi-bin/webscr?cmd=_express-checkout&token=EC-123&useraction=commit' end it 'requests the initial payment after PayPal redirects back' do stub_paypal_response({ 'METHOD' => 'DoExpressCheckoutPayment', 'PAYMENTREQUEST_0_PAYMENTACTION' => 'Sale', 'TOKEN' => 'EC-123', 'PAYERID' => 'PAYER-123', 'PAYMENTREQUEST_0_AMT' => '5.0', 'AMT' => '5.0' }, 'ACK=Success&PAYMENTINFO_0_ACK=Success&PAYMENTINFO_0_PAYMENTSTATUS=Completed') response = PayPalRecurring.new(recurring_params.merge( token: 'EC-123', payer_id: 'PAYER-123' )).request_payment _(response.approved?).must_equal true _(response.completed?).must_equal true end it 'creates the recurring payments profile' do stub_paypal_response({ 'METHOD' => 'CreateRecurringPaymentsProfile', 'TOKEN' => 'EC-123', 'PAYERID' => 'PAYER-123', 'PROFILEREFERENCE' => '42', 'PROFILESTARTDATE' => '2026-01-02T03:04:05Z', 'MAXFAILEDPAYMENTS' => '3', 'AUTOBILLOUTAMT' => 'AddToNextBilling', 'BILLINGFREQUENCY' => '1', 'BILLINGPERIOD' => 'Month' }, 'ACK=Success&PROFILEID=I-123&PROFILESTATUS=ActiveProfile') response = PayPalRecurring.new(authorization_params.merge( frequency: 1, token: 'EC-123', period: :monthly, reference: '42', payer_id: 'PAYER-123', start_at: Time.utc(2026, 1, 2, 3, 4, 5), failed: 3, outstanding: :next_billing )).create_recurring_profile _(response.valid?).must_equal true _(response.profile_id).must_equal 'I-123' end it 'cancels a recurring payments profile' do stub_paypal_response({ 'METHOD' => 'ManageRecurringPaymentsProfileStatus', 'ACTION' => 'Cancel', 'PROFILEID' => 'I-123' }, 'ACK=Success&PROFILEID=I-123&PROFILESTATUS=Cancelled') response = PayPalRecurring.new(profile_id: 'I-123').cancel _(response.valid?).must_equal true _(response.profile_id).must_equal 'I-123' end it 'collects PayPal NVP errors' do stub_paypal_response({ 'METHOD' => 'SetExpressCheckout' }, 'ACK=Failure&L_ERRORCODE0=10001&L_SHORTMESSAGE0=Internal%20Error&L_LONGMESSAGE0=Try%20again') response = PayPalRecurring.new(authorization_params).checkout _(response.valid?).must_equal false _(response.errors).must_equal [{ code: '10001', messages: ['Internal Error', 'Try again'] }] end it 'uses peer verification with the system certificate store' do client = PayPalRecurring.new.send(:http_client, URI(PayPalRecurring.api_endpoint)) _(client.use_ssl?).must_equal true _(client.verify_mode).must_equal OpenSSL::SSL::VERIFY_PEER _(client.ca_file).must_be_nil _(client.cert_store).must_be_instance_of OpenSSL::X509::Store end it 'sets explicit network timeouts' do client = PayPalRecurring.new.send(:http_client, URI(PayPalRecurring.api_endpoint)) _(client.open_timeout).must_equal PayPalRecurring::OPEN_TIMEOUT _(client.read_timeout).must_equal PayPalRecurring::READ_TIMEOUT _(client.write_timeout).must_equal PayPalRecurring::WRITE_TIMEOUT end it 'returns an invalid response for network errors' do stub_request(:post, PayPalRecurring.api_endpoint).to_timeout response = PayPalRecurring.new(profile_id: 'I-123').cancel _(response.valid?).must_equal false _(response.network_error?).must_equal true _(response.errors.first[:code]).must_equal 'network_error' end end describe 'PayPal supporter routes' do include Rack::Test::Methods include PayPalRecurringTestHelpers def app Sinatra::Application end before do configure_test_paypal @site = Fabricate :site end after do restore_paypal_config end it 'does not upgrade the site when recurring profile creation fails' do stub_paypal_response({ 'METHOD' => 'DoExpressCheckoutPayment', 'TOKEN' => 'EC-123', 'PAYERID' => 'PAYER-123' }, 'ACK=Success&PAYMENTINFO_0_ACK=Success&PAYMENTINFO_0_PAYMENTSTATUS=Completed') stub_paypal_response({ 'METHOD' => 'CreateRecurringPaymentsProfile', 'TOKEN' => 'EC-123', 'PAYERID' => 'PAYER-123' }, 'ACK=Failure&L_ERRORCODE0=10001&L_SHORTMESSAGE0=Internal%20Error') get '/supporter/paypal/return', {token: 'EC-123', PayerID: 'PAYER-123'}, {'rack.session' => {'id' => @site.id}} _(last_response.status).must_equal 302 _(last_response.headers['Location']).must_equal 'http://example.org/supporter' @site.reload _(@site.values[:paypal_active]).wont_equal true _(@site.paypal_profile_id).must_be_nil _(@site.plan_type).must_equal 'free' end it 'does not clear local PayPal membership when cancellation fails' do @site.update( paypal_active: true, paypal_profile_id: 'I-123', paypal_token: 'EC-123', plan_type: 'supporter', plan_ended: false ) stub_paypal_response({ 'METHOD' => 'ManageRecurringPaymentsProfileStatus', 'ACTION' => 'Cancel', 'PROFILEID' => 'I-123' }, 'ACK=Failure&L_ERRORCODE0=10001&L_SHORTMESSAGE0=Internal%20Error') post '/supporter/end', {csrf_token: 'abcd'}, {'rack.session' => {'id' => @site.id, '_csrf_token' => 'abcd'}} _(last_response.status).must_equal 302 _(last_response.headers['Location']).must_equal 'http://example.org/settings' @site.reload _(@site.paypal_active).must_equal true _(@site.paypal_profile_id).must_equal 'I-123' _(@site.paypal_token).must_equal 'EC-123' _(@site.plan_type).must_equal 'supporter' _(@site.plan_ended).must_equal false end end