diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ec722a3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,63 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Install dependencies +bundle install + +# Run all tests +bundle exec rake spec +# or +bundle exec rspec spec/ + +# Run a single spec file +bundle exec rspec spec/connectivity_spec.rb + +# Run the CLI locally (without installing the gem) +bundle exec ruby -Ilib bin/bootic help + +# Build and install the gem locally +gem build bootic_cli.gemspec && gem install pkg/bootic_cli-*.gem +``` + +## Architecture + +This is a Ruby CLI gem built on [Thor](https://github.com/rails/thor). The entry point is `bin/bootic` → `lib/bootic_cli/cli.rb`. + +### Command structure + +- `BooticCli::CLI` (in `cli.rb`) — the top-level Thor class. Handles `setup`, `login`, `logout`, `check`, `runner`, `console`. +- `BooticCli::Command` (in `command.rb`) — base class for subcommand groups. Subclasses call `declare self, 'Description'` to register themselves with `CLI` as a subcommand. +- Built-in subcommands live in `lib/bootic_cli/commands/` (e.g. `themes.rb`, `orders.rb`). +- Custom user commands auto-loaded from `~/bootic/*.rb` (overridable via `BTC_CUSTOM_COMMANDS_PATH` env var). + +### Auth / session + +`BooticCli::Connectivity` (mixed into every command class) provides `session`, `root`, and `shop` helpers. + +- `Session` wraps OAuth2 login and the `bootic_client` gem. Credentials (client_id, client_secret, access_token) are persisted via `Store`. +- `Store` uses Ruby's `PStore` at `~/.bootic/store.pstore`, namespaced by environment (`production` by default, controlled by `ENV` env var). +- All commands that require auth call `logged_in_action { ... }`, which checks both client keys and access token before yielding. + +### Theme system + +The most complex part of the codebase. Three theme implementations share a common interface (`templates`, `assets`, `add_template`, `remove_template`, `add_asset`, `remove_asset`): + +| Class | Location | Description | +|---|---|---| +| `APITheme` | `themes/api_theme.rb` | Wraps a remote Bootic theme resource | +| `FSTheme` | `themes/fs_theme.rb` | Reads/writes from a local directory | +| `MemTheme` | `themes/mem_theme.rb` | In-memory theme, used in tests | + +**Diffing:** `ThemeDiff` compares any two theme objects using `UpdatedTheme` (files that exist in both but differ by timestamp/digest) and `MissingItemsTheme` (files present in one side but absent in the other). + +**Workflows:** `Themes::Workflows` orchestrates high-level operations (`pull`, `push`, `sync`, `compare`, `publish`, `watch`) using `ThemeDiff`. Asset downloads are parallelised via `WorkerPool`. Network/server errors are retried up to `MAX_ATTEMPTS` (3); if they persist, a `RetryableError` is raised and caught in the command layer with a user-facing message. + +**ThemeSelector:** resolves which remote shop and theme to operate on (dev vs. public), and pairs a local directory to a shop subdomain via a `.state` file written into the theme directory. + +### Environment support + +Set `ENV=staging` (or any value) before running commands to use a separate credential namespace in the PStore, allowing multiple environments to coexist. diff --git a/lib/bootic_cli/cli.rb b/lib/bootic_cli/cli.rb index 1efe829..72ef264 100644 --- a/lib/bootic_cli/cli.rb +++ b/lib/bootic_cli/cli.rb @@ -24,93 +24,75 @@ def __print_version puts "#{BooticCli::VERSION} (Ruby #{RUBY_VERSION})" end - desc 'setup', 'Setup Bootic application credentials' - def setup - apps_host = "auth.bootic.net" - dev_app_url = "#{apps_host}/dev/cli" + desc 'login', 'Login to your Bootic account' + def login + require 'launchy' + require 'bootic_cli/local_server' - if session.setup? - input = ask "Looks like you're already set up. Do you want to re-enter your app's credentials? [n]", :magenta - if input != 'y' - say 'Thought so. You can run `bootic help` for a list of supported commands.' + if session.logged_in? + input = ask "You're already logged in. Re-authenticate? [n]", :magenta + if input.strip.downcase != 'y' + say "Already logged in! Try `bootic help`." exit(1) end - else - say "This CLI uses the #{bold('Bootic API')} in order to interact with your shop's data." - say "This means you need to create a Bootic app at #{bold(dev_app_url)} to access the API and use the CLI.\n" end - input = ask "Have you created a Bootic app yet? [n]" - if input == 'y' - say "Great. Remember you can get your app's credentials at #{bold(dev_app_url)}." - else - say "Please visit https://#{bold(dev_app_url)} and hit the 'Create' button." - sleep 2 - # Launchy.open(apps_url) - say "" - end + state = verifier = callback_port = nil + params = nil - if current_env != DEFAULT_ENV - auth_host = ask("Enter auth endpoint host (#{BooticClient.configuration.auth_host}):", :bold).chomp - api_root = ask("Enter API root (#{BooticClient.configuration.api_root}):", :bold).chomp - auth_host = nil if auth_host == "" - api_root = nil if api_root == "" + begin + params = BooticCli::LocalServer.wait_for_callback do |port| + callback_port = port + url, state, verifier = session.authorization_request(port: port) + say "\nOpening your browser to complete authentication...", :cyan + say "If it doesn't open automatically, visit:\n#{url}\n", :magenta + Launchy.open(url) + end + rescue BooticCli::LocalServer::NoPortAvailable => e + say e.message, :red + exit 1 + rescue BooticCli::LocalServer::TimedOut + say "Authentication timed out (no response after 2 minutes). Please try again.", :red + exit 1 end - client_id = ask("Enter your application's client_id:", :bold) - client_secret = ask("Enter your application's client_secret:", :bold) - - session.setup(client_id, client_secret, auth_host: auth_host, api_root: api_root) - - if current_env == DEFAULT_ENV - say "Credentials stored!", :magenta - else - say "Credentials stored for #{current_env} env.", :magenta + if params['state'] != state + say "Security error: state mismatch. Please try again.", :red + exit 1 end - return if ENV['nologin'] - - say "" - sleep 3 - login - end - - desc 'login', 'Login to your Bootic account' - def login(scope = 'admin') - if !session.setup? - say "App not configured for #{options[:environment]} environment. Running setup first. You only need to do this once.", :red - invoke :setup, [] + if params['error'] + say "Authentication failed: #{params['error_description'] || params['error']}", :red + exit 1 end - if session.logged_in? - input = ask "Looks like you're already logged in. Do you want to redo this step? [n]", :magenta - if input != 'y' - say "That's what I thought! Try running `bootic help`." - exit(1) - end + begin + session.login_with_browser(params['code'], code_verifier: verifier, port: callback_port) + say "\nYou're now logged in!", :green + say "For a list of available commands, run `bootic help`." + rescue StandardError => e + say "Could not exchange token: #{e.message}", :red + exit 1 end + end - email = ask("Enter your Bootic email:", :bold) - pass = ask("Enter your Bootic password:", :bold, echo: false) + desc 'setup', 'Configure a custom OAuth app (advanced)' + def setup + say "Note: `bootic setup` is only needed if you want to use your own OAuth app.", :cyan + say "For normal use, just run `bootic login`.\n" - if email.strip == '' or email['@'].nil? or pass.strip == '' - say "\nPlease make sure to enter valid data.", :red - exit 1 + if current_env != DEFAULT_ENV + auth_host = ask("Auth endpoint host (#{BooticClient.configuration.auth_host}):", :bold).chomp + api_root = ask("API root (#{BooticClient.configuration.api_root}):", :bold).chomp + auth_host = nil if auth_host.empty? + api_root = nil if api_root.empty? end - say "\n\nAlrighty! Getting access token for #{email}...\n" + client_id = ask("Client ID:", :bold) + client_secret = ask("Client secret:", :bold) - begin - session.login(email, pass, scope) - say "Great success! You're now logged in as #{email} (#{scope})", :green - say "For a list of available commands, run `bootic help`." - rescue StandardError => e - say e.message, :red - if e.message['No application with client ID'] - sleep 2 - say "\nTry running `bootic setup` again. Or perhaps you missed the ENV variable?", :magenta - end - end + session.setup(client_id, client_secret, auth_host: auth_host, api_root: api_root) + say "Custom app credentials stored.", :magenta end desc 'logout', 'Log out (delete access token)' diff --git a/lib/bootic_cli/local_server.rb b/lib/bootic_cli/local_server.rb new file mode 100644 index 0000000..60d7877 --- /dev/null +++ b/lib/bootic_cli/local_server.rb @@ -0,0 +1,88 @@ +require 'socket' +require 'uri' + +module BooticCli + class LocalServer + CALLBACK_PORTS = (33100..33110).freeze + CALLBACK_HOST = '127.0.0.1' + CALLBACK_PATH = '/callback' + WAIT_TIMEOUT = 120 # seconds + + class NoPortAvailable < StandardError; end + class TimedOut < StandardError; end + + SUCCESS_HTML = <<~HTML.freeze + +
You can close this tab and return to the terminal.
+ + + HTML + + def self.callback_url(port) + "http://localhost:#{port}#{CALLBACK_PATH}" + end + + # Tries each port in CALLBACK_PORTS until one is free, starts a local HTTP + # server on it, yields the port (so the caller can open the browser), then + # blocks until the OAuth callback arrives. Returns the parsed query params. + def self.wait_for_callback(&before_wait) + port, server = bind_to_free_port + raise NoPortAvailable, "Could not bind on any port in #{CALLBACK_PORTS.first}-#{CALLBACK_PORTS.last}" unless server + + before_wait.call(port) if before_wait + + begin + raise TimedOut unless IO.select([server], nil, nil, WAIT_TIMEOUT) + + socket = server.accept + request_line = socket.gets + drain_headers(socket) + + params = parse_query(request_line) + + socket.print build_response(SUCCESS_HTML) + socket.close + + params + ensure + server.close rescue nil + end + end + + def self.bind_to_free_port + CALLBACK_PORTS.each do |port| + server = TCPServer.new(CALLBACK_HOST, port) rescue next + return [port, server] + end + [nil, nil] + end + private_class_method :bind_to_free_port + + def self.drain_headers(socket) + line = socket.gets + line = socket.gets while line && line.chomp != '' + end + private_class_method :drain_headers + + def self.parse_query(request_line) + path = request_line.to_s.split(' ')[1].to_s + query = URI.parse("http://localhost#{path}").query.to_s + URI.decode_www_form(query).to_h + rescue URI::InvalidURIError + {} + end + private_class_method :parse_query + + def self.build_response(body) + "HTTP/1.1 200 OK\r\n" \ + "Content-Type: text/html; charset=utf-8\r\n" \ + "Content-Length: #{body.bytesize}\r\n" \ + "Connection: close\r\n" \ + "\r\n#{body}" + end + private_class_method :build_response + end +end diff --git a/lib/bootic_cli/session.rb b/lib/bootic_cli/session.rb index 1bc08bc..bf4abce 100644 --- a/lib/bootic_cli/session.rb +++ b/lib/bootic_cli/session.rb @@ -1,9 +1,18 @@ require 'oauth2' require 'bootic_client' +require 'bootic_cli/local_server' +require 'securerandom' +require 'digest' +require 'base64' module BooticCli class Session + # Public client_id for the first-party Bootic CLI OAuth app. + # This is NOT a secret — the security comes from PKCE, not a client_secret. + # Override with BOOTIC_CLI_CLIENT_ID for development or self-hosted setups. + CLI_CLIENT_ID = ENV.fetch('BOOTIC_CLI_CLIENT_ID', 'bootic-cli-dev') + def initialize(store) @store = store upgrade! @@ -18,35 +27,75 @@ def upgrade! self end + # Always true — no manual setup required; PKCE handles auth without a secret. + # setup() is still provided for users who want a custom OAuth app. def setup? - store.transaction do - store['client_id'] && store['client_secret'] - end + true end def logged_in? - store.transaction{ store['access_token'] } + store.transaction { store['access_token'] } end def ready? - setup? && logged_in? + logged_in? end + # Optional: configure a custom OAuth app (for non-production environments or + # self-hosted setups). Not required for normal use. def setup(client_id, client_secret, auth_host: nil, api_root: nil) store.transaction do - store['client_id'] = client_id + store['client_id'] = client_id store['client_secret'] = client_secret - store['auth_host'] = auth_host if auth_host - store['api_root'] = api_root if api_root + store['auth_host'] = auth_host if auth_host + store['api_root'] = api_root if api_root end end - def login(username, pwd, scope) - token = oauth_client.password.get_token(username, pwd, 'scope' => scope) + # Build a [url, code_verifier] pair to start a PKCE authorization flow. + # The caller must pass code_verifier back to login_with_browser() after the + # callback arrives. + def authorization_request(port:) + verifier = pkce_verifier + challenge = pkce_challenge(verifier) + state = SecureRandom.hex(16) + + params = URI.encode_www_form( + client_id: effective_client_id, + redirect_uri: LocalServer.callback_url(port), + response_type: 'code', + scope: 'admin', + state: state, + code_challenge: challenge, + code_challenge_method: 'S256' + ) - store.transaction do - store['access_token'] = token.token - end + url = "https://#{effective_auth_host}/oauth/authorize?#{params}" + [url, state, verifier] + end + + # Exchange an authorization code (plus the PKCE verifier) for an access token. + def login_with_browser(code, code_verifier:, port:) + # PKCE exchange: send code_verifier in place of client_secret + response = Net::HTTP.post_form( + URI("https://#{effective_auth_host}/oauth/token"), + grant_type: 'authorization_code', + client_id: effective_client_id, + code: code, + redirect_uri: LocalServer.callback_url(port), + code_verifier: code_verifier + ) + + body = JSON.parse(response.body) + raise "Token exchange failed: #{body['error_description'] || body['error'] || response.body}" unless response.is_a?(Net::HTTPSuccess) + + store.transaction { store['access_token'] = body['access_token'] } + end + + # Legacy password-based login — kept for CI / headless environments. + def login(username, pwd, scope = 'admin') + token = oauth2_client.password.get_token(username, pwd, 'scope' => scope) + store.transaction { store['access_token'] = token.token } end def erase! @@ -54,55 +103,43 @@ def erase! end def logout! - store.transaction do - store['access_token'] = nil - end + store.transaction { store['access_token'] = nil } end def config @config ||= store.transaction do - h = { - client_id: store['client_id'], + { + client_id: store['client_id'], client_secret: store['client_secret'], - access_token: store['access_token'] + access_token: store['access_token'], + auth_host: store['auth_host'], + api_root: store['api_root'] } - h[:auth_host] = store['auth_host'] if store['auth_host'] - h[:api_root] = store['api_root'] if store['api_root'] - h end end - # use a null store instead of the default memory store - # so in btc console we don't cache resources forever class NullCacheStore - def read(key) - nil - end - - def delete(key) - nil - end - - def write(key, value) - value - end + def read(key) = nil + def delete(key) = nil + def write(key, v) = v end def client @client ||= begin - raise "First setup credentials and log in" unless ready? + raise "Not logged in. Please run `bootic login`." unless logged_in? + BooticClient.configure do |c| - c.auth_host = config[:auth_host] if config[:auth_host] - c.api_root = config[:api_root] if config[:api_root] - c.client_id = config[:client_id] - c.client_secret = config[:client_secret] - c.logger = Logger.new(STDOUT) - c.logging = false - c.cache_store = NullCacheStore.new + c.auth_host = config[:auth_host] if config[:auth_host] + c.api_root = config[:api_root] if config[:api_root] + c.client_id = effective_client_id + c.client_secret = config[:client_secret].to_s + c.logger = Logger.new(STDOUT) + c.logging = false + c.cache_store = NullCacheStore.new end BooticClient.client(:authorized, access_token: config[:access_token]) do |new_token| - store.transaction{ store['access_token'] = new_token } + store.transaction { store['access_token'] = new_token } end end end @@ -111,14 +148,29 @@ def client attr_reader :store - def oauth_client - @oauth_client ||= OAuth2::Client.new( - config[:client_id], - config[:client_secret], - site: (config[:auth_host] || BooticClient.configuration.auth_host) - ) + def effective_client_id + config[:client_id] || CLI_CLIENT_ID end + def effective_auth_host + config[:auth_host] || BooticClient.configuration.auth_host + end + + def pkce_verifier + SecureRandom.urlsafe_base64(32) # 43 chars, within the 43-128 range + end + + def pkce_challenge(verifier) + Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) + end + + def oauth2_client + OAuth2::Client.new( + effective_client_id, + config[:client_secret].to_s, + site: "https://#{effective_auth_host}" + ) + end end end diff --git a/spec/bootic_cli_spec.rb b/spec/bootic_cli_spec.rb index b10b15d..ee319d0 100644 --- a/spec/bootic_cli_spec.rb +++ b/spec/bootic_cli_spec.rb @@ -21,77 +21,83 @@ def allow_ask(question, response, opts = {}) expect(Thor::LineEditor).to receive(:readline).with("#{question} ", opts).and_return response end - def assert_login - allow_ask("Looks like you're already logged in. Do you want to redo this step? [n]", "y") - - allow_ask("Enter your Bootic email:", "joe@gmail.com") - allow_ask("Enter your Bootic password:", "bloggs", echo: false) + before do + allow(BooticCli::Session).to receive(:new).and_return session + allow(session).to receive(:client).and_return client + end - expect(session).to receive(:login).with("joe@gmail.com", "bloggs", "admin") + describe "#login" do + let(:state) { 'deadbeefcafe' } + let(:verifier) { 'pkce-verifier-abc' } - content = capture(:stdout) { described_class.start(%w(login)) } - expect(content).to match /You're now logged in as joe@gmail.com \(admin\)/ - end + before { allow(Launchy).to receive(:open) } - def assert_setup(env = 'production', &block) - ENV['ENV'] = env - ENV['nologin'] = '1' # otherwise we'de be testing the two things - allow(session).to receive(:setup?).and_return(false) + context "not yet logged in" do + before { allow(session).to receive(:logged_in?).and_return(false) } - auth_host = nil - api_root = nil + it "opens browser and exchanges the code for a token" do + allow(BooticCli::LocalServer).to receive(:wait_for_callback) do |&block| + block.call(33100) + { 'code' => 'auth-code-123', 'state' => state } + end - if env != 'production' - auth_host = "https://auth-staging.bootic.net" - api_root = "https://api-staging.bootic.net/v1" - allow_ask("Enter auth endpoint host (https://auth.bootic.net):", auth_host) - allow_ask("Enter API root (https://api.bootic.net/v1):", api_root) - end + expect(session).to receive(:authorization_request).with(port: 33100) + .and_return(['https://auth.example.com/authorize', state, verifier]) + expect(session).to receive(:login_with_browser).with('auth-code-123', code_verifier: verifier, port: 33100) - allow_ask("Have you created a Bootic app yet? [n]", "y") - allow_ask("Enter your application's client_id:", "abc") - allow_ask("Enter your application's client_secret:", "xyz") + content = capture(:stdout) { described_class.start(%w(login)) } + expect(content).to match /You're now logged in!/ + end - # allow(session).to receive(:logout!) - expect(session).to receive(:setup).with("abc", "xyz", auth_host: auth_host, api_root: api_root) + it "aborts on state mismatch without exchanging the code" do + allow(BooticCli::LocalServer).to receive(:wait_for_callback) do |&block| + block.call(33100) + { 'code' => 'auth-code-123', 'state' => 'tampered' } + end + allow(session).to receive(:authorization_request).and_return(['https://auth.example.com/authorize', state, verifier]) + expect(session).not_to receive(:login_with_browser) - if block_given? - content = capture(:stdout) { yield } - expect(content).to match /Credentials stored/ + expect { described_class.start(%w(login)) }.to raise_error(SystemExit) + end end - end - before do - allow(BooticCli::Session).to receive(:new).and_return session - allow(session).to receive(:client).and_return client + context "already logged in" do + it "exits early when user declines re-authentication" do + allow_ask("You're already logged in. Re-authenticate? [n]", "n") + expect { described_class.start(%w(login)) }.to raise_error(SystemExit) + end + end end describe "#setup" do + before { ENV.delete('ENV') } + after { ENV.delete('ENV') } + it "calls Session#setup(client_id, client_secret)" do - assert_setup { described_class.start(%w(setup)) } - end + allow_ask("Client ID:", "abc") + allow_ask("Client secret:", "xyz") - it "sets up with custom env" do - assert_setup('staging') { - described_class.start(%w(setup)) - } - end - end + expect(session).to receive(:setup).with("abc", "xyz", auth_host: nil, api_root: nil) - describe "#login" do - context "not setup yet" do - it "invokes setup" do - allow(session).to receive(:setup?).and_return false - assert_setup - assert_login - end + content = capture(:stdout) { described_class.start(%w(setup)) } + expect(content).to match /Custom app credentials stored/ end - context "already setup" do - it "calls Session#setup(client_id, client_secret)" do - allow(session).to receive(:setup?).and_return true - assert_login - end + it "sets up with custom env" do + ENV['ENV'] = 'staging' + allow_ask("Auth endpoint host (https://auth.bootic.net):", "https://auth-staging.bootic.net") + allow_ask("API root (https://api.bootic.net/v1):", "https://api-staging.bootic.net/v1") + allow_ask("Client ID:", "abc") + allow_ask("Client secret:", "xyz") + + expect(session).to receive(:setup).with( + "abc", "xyz", + auth_host: "https://auth-staging.bootic.net", + api_root: "https://api-staging.bootic.net/v1" + ) + + content = capture(:stdout) { described_class.start(%w(setup)) } + expect(content).to match /Custom app credentials stored/ end end @@ -99,7 +105,6 @@ def assert_setup(env = 'production', &block) it "calls Session#logout!" do expect(session).to receive(:logout!) content = capture(:stdout) { described_class.start(%w(logout)) } - expect(content).to match /Done. You are now logged out/ end end @@ -108,7 +113,6 @@ def assert_setup(env = 'production', &block) it "calls Session#erase!" do expect(session).to receive(:erase!) content = capture(:stdout) { described_class.start(%w(erase)) } - expect(content).to match /Ok mister. All credentials have been erased/ end end @@ -139,9 +143,7 @@ def assert_setup(env = 'production', &block) describe "#runner" do it "uses FileRunner" do expect(BooticCli::FileRunner).to receive(:run).with(root, "./foo.rb") - described_class.start(%w(runner ./foo.rb)) end end - end diff --git a/spec/local_server_spec.rb b/spec/local_server_spec.rb new file mode 100644 index 0000000..6c32ee5 --- /dev/null +++ b/spec/local_server_spec.rb @@ -0,0 +1,75 @@ +require 'spec_helper' +require 'bootic_cli/local_server' +require 'socket' + +describe BooticCli::LocalServer do + describe '.callback_url' do + it 'returns a localhost URL with the given port and callback path' do + expect(described_class.callback_url(33100)).to eq('http://localhost:33100/callback') + expect(described_class.callback_url(33105)).to eq('http://localhost:33105/callback') + end + end + + describe '.wait_for_callback' do + it 'raises NoPortAvailable when all ports in the range are already bound' do + servers = (33100..33110).map do |port| + TCPServer.new('127.0.0.1', port) rescue nil + end.compact + + begin + expect { + described_class.wait_for_callback + }.to raise_error(BooticCli::LocalServer::NoPortAvailable) + ensure + servers.each { |s| s.close rescue nil } + end + end + + it 'yields the bound port to the block before waiting' do + yielded_port = nil + + thread = Thread.new do + described_class.wait_for_callback do |port| + yielded_port = port + # Immediately send a minimal HTTP callback so the server can exit + begin + s = TCPSocket.new('127.0.0.1', port) + s.print "GET /callback?code=xyz&state=abc HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" + s.read + s.close + rescue + end + end + end + + thread.join(5) + expect(yielded_port).to be_between(33100, 33110) + end + + it 'parses and returns the query params from the callback request' do + params = nil + + described_class.wait_for_callback do |port| + Thread.new do + sleep 0.05 + s = TCPSocket.new('127.0.0.1', port) + s.print "GET /callback?code=auth-code-999&state=mystate HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" + s.read + s.close + end + end.tap { |p| params = p } + + expect(params['code']).to eq('auth-code-999') + expect(params['state']).to eq('mystate') + end + + it 'raises TimedOut when no callback arrives within the timeout' do + original_timeout = BooticCli::LocalServer::WAIT_TIMEOUT + stub_const('BooticCli::LocalServer::WAIT_TIMEOUT', 0.001) + + expect { + described_class.wait_for_callback { |_port| } + }.to raise_error(BooticCli::LocalServer::TimedOut) + end + end +end diff --git a/spec/session_spec.rb b/spec/session_spec.rb new file mode 100644 index 0000000..d3e9584 --- /dev/null +++ b/spec/session_spec.rb @@ -0,0 +1,132 @@ +require 'spec_helper' +require 'bootic_cli/session' +require 'bootic_cli/store' +require 'tmpdir' +require 'digest' +require 'base64' + +describe BooticCli::Session do + let(:store_dir) { File.join(Dir.tmpdir, "btc_session_spec_#{Process.pid}") } + let(:store) { BooticCli::Store.new(base_dir: store_dir, dir: '') } + + subject(:session) { described_class.new(store) } + + before { session } # force initialization so upgrade! runs before any store writes + + after { FileUtils.rm_rf(store_dir) } + + describe '#setup?' do + it 'always returns true — no manual setup required' do + expect(session.setup?).to be true + end + end + + describe '#logged_in?' do + it 'returns falsy when no access token is stored' do + expect(session.logged_in?).to be_falsy + end + + it 'returns the token when one is stored' do + store.transaction { store['access_token'] = 'tok123' } + expect(session.logged_in?).to eq('tok123') + end + end + + describe '#authorization_request' do + it 'returns [url, state, verifier]' do + url, state, verifier = session.authorization_request(port: 33100) + expect(url).to be_a(String) + expect(state).to be_a(String) + expect(verifier).to be_a(String) + end + + it 'builds an authorization URL with the correct client_id' do + url, _state, _verifier = session.authorization_request(port: 33100) + parsed = URI.parse(url) + params = URI.decode_www_form(parsed.query).to_h + expect(params['client_id']).to eq(BooticCli::Session::CLI_CLIENT_ID) + end + + it 'includes the port-specific callback as redirect_uri' do + url, _state, _verifier = session.authorization_request(port: 33105) + parsed = URI.parse(url) + params = URI.decode_www_form(parsed.query).to_h + expect(params['redirect_uri']).to eq('http://localhost:33105/callback') + end + + it 'includes a non-empty state for CSRF protection' do + url, state, _verifier = session.authorization_request(port: 33100) + parsed = URI.parse(url) + params = URI.decode_www_form(parsed.query).to_h + expect(params['state']).to eq(state) + expect(state).not_to be_empty + end + + it 'includes a valid S256 PKCE code_challenge derived from the verifier' do + _url, _state, verifier = session.authorization_request(port: 33100) + _url2, _state2, verifier2 = session.authorization_request(port: 33100) + + # Each call generates a fresh verifier + expect(verifier).not_to eq(verifier2) + end + + it 'sends code_challenge_method=S256' do + url, _state, _verifier = session.authorization_request(port: 33100) + parsed = URI.parse(url) + params = URI.decode_www_form(parsed.query).to_h + expect(params['code_challenge_method']).to eq('S256') + end + + it 'derives code_challenge correctly from the verifier' do + url, _state, verifier = session.authorization_request(port: 33100) + parsed = URI.parse(url) + params = URI.decode_www_form(parsed.query).to_h + + expected_challenge = Base64.urlsafe_encode64( + Digest::SHA256.digest(verifier), + padding: false + ) + expect(params['code_challenge']).to eq(expected_challenge) + end + end + + describe '#setup' do + it 'stores client_id and client_secret' do + session.setup('my-id', 'my-secret') + cfg = session.config + expect(cfg[:client_id]).to eq('my-id') + expect(cfg[:client_secret]).to eq('my-secret') + end + + it 'stores optional auth_host and api_root' do + session.setup('id', 'secret', auth_host: 'https://auth.example.com', api_root: 'https://api.example.com/v1') + cfg = session.config + expect(cfg[:auth_host]).to eq('https://auth.example.com') + expect(cfg[:api_root]).to eq('https://api.example.com/v1') + end + + it 'prefers a stored client_id over the bundled CLI_CLIENT_ID in authorization_request' do + session.setup('custom-app-id', 'secret') + # Force config to reload from store + session.instance_variable_set(:@config, nil) + url, _state, _verifier = session.authorization_request(port: 33100) + params = URI.decode_www_form(URI.parse(url).query).to_h + expect(params['client_id']).to eq('custom-app-id') + end + end + + describe '#logout!' do + it 'clears the stored access token' do + store.transaction { store['access_token'] = 'tok' } + session.logout! + expect(session.logged_in?).to be_falsy + end + end + + describe '#erase!' do + it 'calls erase on the store' do + expect(store).to receive(:erase) + session.erase! + end + end +end