From baf949d88d1a714c03d853bbb6a3ada96add5284 Mon Sep 17 00:00:00 2001 From: Ernesto Tagwerker Date: Sat, 16 May 2026 15:45:26 -0400 Subject: [PATCH] Allow PAT-authenticated clients to create shares via JSON API Adds POST /links/:link_id/shares.json to the JSON API so a user holding a personal access token can create shareable links for a link (paired with the existing GET endpoint for querying them). - whitelist shares#create in API_ENDPOINTS - SharesController#create now responds to JSON with 201/422 - shortened_url assignment is rescued so a Rebrandly failure doesn't leak a 500 after the share is already persisted - request specs cover success, validation errors, auth, and the shorten-failure path Co-Authored-By: Claude Opus 4.7 (1M context) --- app/controllers/application_controller.rb | 2 +- app/controllers/shares_controller.rb | 20 +++++-- spec/requests/api_links_spec.rb | 71 ++++++++++++++++++++++- 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 234f9ee..ba157a0 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -4,7 +4,7 @@ class ApplicationController < ActionController::Base # so a PAT cannot reach write endpoints via content negotiation. API_ENDPOINTS = { "links" => %w[index show], - "shares" => %w[index] + "shares" => %w[index create] }.freeze skip_forgery_protection if: :api_endpoint? diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb index 0eb5e6a..ef996ac 100644 --- a/app/controllers/shares_controller.rb +++ b/app/controllers/shares_controller.rb @@ -31,11 +31,15 @@ def clone def create @share = @link.shares.build(share_params) - if @share.save - @share.update shortened_url: "https://#{@share.shorten}" - redirect_to @link, notice: "Share was successfully created." - else - render :new, status: :unprocessable_entity + respond_to do |format| + if @share.save + assign_shortened_url(@share) + format.html { redirect_to @link, notice: "Share was successfully created." } + format.json { render json: share_json(@share), status: :created } + else + format.html { render :new, status: :unprocessable_entity } + format.json { render json: { errors: @share.errors.full_messages }, status: :unprocessable_entity } + end end end @@ -55,6 +59,12 @@ def share_params params.require(:share).permit(:link_id, :shortened_url, :utm_source, :utm_medium, :utm_campaign, :utm_term, :utm_content, :utm_id, :shared_link_name) end + def assign_shortened_url(share) + share.update(shortened_url: "https://#{share.shorten}") + rescue StandardError => e + Rails.logger.warn("Share ##{share.id} shorten failed: #{e.class}: #{e.message}") + end + def share_json(share) { id: share.id, diff --git a/spec/requests/api_links_spec.rb b/spec/requests/api_links_spec.rb index 6d70503..73c9af0 100644 --- a/spec/requests/api_links_spec.rb +++ b/spec/requests/api_links_spec.rb @@ -131,15 +131,80 @@ end end - describe 'Bearer cannot reach write endpoints' do - it 'cannot POST a share' do + describe 'POST /links/:link_id/shares.json' do + before do + allow_any_instance_of(Share).to receive(:shorten).and_return('short.link/abc') + end + + let(:valid_params) do + { + share: { + utm_source: 'LinkedIn', + utm_medium: 'community', + utm_campaign: 'campaignOne', + utm_term: 'termOne', + utm_content: 'Photo' + } + } + end + + it 'creates a share for a valid token' do + expect { + post "/links/#{fastruby_link.id}/shares.json", params: valid_params, headers: auth_headers + }.to change(Share, :count).by(1) + + expect(response).to have_http_status(:created) + body = JSON.parse(response.body) + expect(body['utm_source']).to eq('LinkedIn') + expect(body['link_id']).to eq(fastruby_link.id) + expect(body['shortened_url']).to eq('https://short.link/abc') + end + + it 'returns 422 with errors when params are invalid' do expect { post "/links/#{fastruby_link.id}/shares.json", - params: { share: { utm_source: 'evil', utm_medium: 'evil', utm_campaign: 'evil' } }, + params: { share: { utm_source: '' } }, headers: auth_headers }.not_to change(Share, :count) + + expect(response).to have_http_status(:unprocessable_entity) + body = JSON.parse(response.body) + expect(body['errors']).to be_an(Array).and(be_present) end + it 'returns 401 without a token' do + expect { + post "/links/#{fastruby_link.id}/shares.json", + params: valid_params, + headers: { 'Accept' => 'application/json' } + }.not_to change(Share, :count) + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns 401 with an invalid token' do + headers = { 'Authorization' => 'Bearer wrong', 'Accept' => 'application/json' } + expect { + post "/links/#{fastruby_link.id}/shares.json", params: valid_params, headers: headers + }.not_to change(Share, :count) + + expect(response).to have_http_status(:unauthorized) + end + + it 'persists the share even if shortening fails' do + allow_any_instance_of(Share).to receive(:shorten).and_raise(StandardError, 'rebrandly down') + + expect { + post "/links/#{fastruby_link.id}/shares.json", params: valid_params, headers: auth_headers + }.to change(Share, :count).by(1) + + expect(response).to have_http_status(:created) + body = JSON.parse(response.body) + expect(body['shortened_url']).to be_nil + end + end + + describe 'Bearer cannot reach write endpoints' do it 'cannot create a personal access token' do auth_headers # materialize the lazy `token` let so it doesn't pollute the count check expect {