diff --git a/README.md b/README.md index 682b8f7..27ec7d7 100644 --- a/README.md +++ b/README.md @@ -116,12 +116,14 @@ fm pages publish --owner growth --html-file report.html --visibility unlisted fm pages asset upload chart.png ``` -FeedMob Workspace is the internal, read-only API for shared operational data. -Obtain a personal access token from FeedMob SSO, then authenticate without -placing the token in shell history. Workspace requests are GET-only and must -stay under `/api/v1/`; access to individual resources is enforced by the -server-side Workspace privileges. Consult the SSO-protected API Reference for -available resources rather than treating CLI help as an endpoint catalog. +FeedMob Workspace is the CLI's read-only access to the FeedMob Admin API for +shared operational data. Obtain a personal access token from FeedMob SSO, then +authenticate without placing the token in shell history. The token owner must +have an approved SSO account. A `401 Unauthorized` response can mean that the +token is invalid, expired, or revoked, or that the account is not approved. +Workspace requests are GET-only and must stay under `/api/v1/`. Consult the +SSO-protected API Reference for available resources rather than treating CLI +help as an endpoint catalog. ```sh fm workspace auth login diff --git a/lib/feedmob/cli/http/client.rb b/lib/feedmob/cli/http/client.rb index 76b1c08..8225725 100644 --- a/lib/feedmob/cli/http/client.rb +++ b/lib/feedmob/cli/http/client.rb @@ -80,6 +80,8 @@ def raise_api_error!(status, data) end def api_error(data, status) + return workspace_unauthorized_error if workspace_unauthorized?(status) + remote_error = data['error'] if data.is_a?(Hash) if remote_error.is_a?(Hash) [remote_error['code'] || 'http_error', remote_error['message'] || default_api_error(status)] @@ -102,6 +104,18 @@ def data_message(data, remote_error, status) def default_api_error(status) "#{@service.label} API returned HTTP #{status}." end + + def workspace_unauthorized?(status) + @service.name == 'workspace' && status == 401 + end + + def workspace_unauthorized_error + [ + 'http_error', + 'FeedMob Workspace could not authenticate with the FeedMob Admin API. ' \ + 'Confirm that the personal access token is active and the FeedMob SSO account is approved.' + ] + end end end end diff --git a/test/http_client_test.rb b/test/http_client_test.rb index 8c17a81..a68ed65 100644 --- a/test/http_client_test.rb +++ b/test/http_client_test.rb @@ -133,4 +133,27 @@ def test_api_error_uses_remote_message_without_exposing_token assert_equal 'Credential expired.', error.message refute_includes error.message, 'fmpat_never-print-me' end + + def test_workspace_unauthorized_error_explains_the_account_approval_requirement + workspace_service = FeedMob::CLI::Services.fetch('workspace', env: {}) + transport = FakeTransport.new( + response: { + status: 401, + headers: { 'content-type' => 'application/json' }, + body: '{"error":"Unauthorized"}' + } + ) + client = FeedMob::CLI::HTTP::Client.new(service: workspace_service, transport:) + + error = assert_raises(FeedMob::CLI::Error) do + client.request(method: :get, path: '/api/v1/me', token: 'fmapat_never-print-me') + end + + expected_message = 'FeedMob Workspace could not authenticate with the FeedMob Admin API. ' \ + 'Confirm that the personal access token is active and the FeedMob SSO account is approved.' + assert_equal 'http_error', error.code + assert_equal expected_message, error.message + assert_equal({ status: 401 }, error.details) + refute_includes error.message, 'fmapat_never-print-me' + end end