# frozen_string_literal: true require "webmock/rspec" RSpec.describe Dispatch::Adapter::Claude, "auth lifecycle" do let(:tmpdir) { Dir.mktmpdir("auth_lifecycle_test") } let(:store_path) { File.join(tmpdir, "claude_oauth.json") } let(:store) { Dispatch::Adapter::Claude::TokenStore.new(path: store_path) } after { FileUtils.rm_rf(tmpdir) } def make_adapter(api_key: nil) described_class.new( model: "claude-sonnet-4-6", api_key: api_key, token_store: store ) end def future_ms(seconds_from_now = 3600) ((Time.now.to_f + seconds_from_now) * 1000).to_i end def past_ms(seconds_ago = 3600) ((Time.now.to_f - seconds_ago) * 1000).to_i end # ── authenticate! — explicit API key ───────────────────────────────────── describe "#authenticate! with an explicit API key" do subject(:adapter) { make_adapter(api_key: "sk-ant-api03-test") } it "returns :api_key" do expect(adapter.authenticate!).to eq(:api_key) end it "does not load the token store" do expect(store).not_to receive(:load) adapter.authenticate! end end # ── authenticate! — valid cached token ─────────────────────────────────── describe "#authenticate! with a valid cached token" do before do store.save( "access_token" => "sk-ant-oat-cached", "refresh_token" => "rt-cached", "expires_at_ms" => future_ms ) end subject(:adapter) { make_adapter } it "returns :cached" do expect(adapter.authenticate!).to eq(:cached) end it "does not call OAuth.refresh!" do expect(Dispatch::Adapter::Claude::OAuth).not_to receive(:refresh!) adapter.authenticate! end it "does not call OAuth.login" do expect(Dispatch::Adapter::Claude::OAuth).not_to receive(:login) adapter.authenticate! end end # ── authenticate! — expired token with refresh_token ───────────────────── describe "#authenticate! with an expired token and a refresh_token" do before do store.save( "access_token" => "sk-ant-oat-expired", "refresh_token" => "rt-old", "expires_at_ms" => past_ms ) allow(Dispatch::Adapter::Claude::OAuth).to receive(:refresh!) .with("rt-old") .and_return( "access_token" => "sk-ant-oat-refreshed", "refresh_token" => "rt-new", "expires_at_ms" => future_ms ) end subject(:adapter) { make_adapter } it "returns :refreshed" do expect(adapter.authenticate!).to eq(:refreshed) end it "calls OAuth.refresh! with the stored refresh_token" do expect(Dispatch::Adapter::Claude::OAuth).to receive(:refresh!).with("rt-old") .and_return( "access_token" => "sk-ant-oat-refreshed", "refresh_token" => "rt-new", "expires_at_ms" => future_ms ) adapter.authenticate! end it "saves the refreshed credentials to the store" do adapter.authenticate! expect(store.load["access_token"]).to eq("sk-ant-oat-refreshed") end it "updates @api_key to the new access token" do adapter.authenticate! # Verify by calling authenticated? — it should return true expect(adapter.authenticated?).to be true end end # ── authenticate! — no credentials → interactive login ─────────────────── describe "#authenticate! with no stored credentials" do subject(:adapter) { make_adapter } before do allow(Dispatch::Adapter::Claude::OAuth).to receive(:login) .and_return( "access_token" => "sk-ant-oat-fresh", "refresh_token" => "rt-fresh", "expires_at_ms" => future_ms ) end it "returns :logged_in" do expect(adapter.authenticate!).to eq(:logged_in) end it "calls OAuth.login with the token_store" do expect(Dispatch::Adapter::Claude::OAuth).to receive(:login) .with(token_store: store) adapter.authenticate! end it "saves credentials after login" do adapter.authenticate! expect(store.load["access_token"]).to eq("sk-ant-oat-fresh") end end # ── authenticated? ──────────────────────────────────────────────────────── describe "#authenticated?" do context "with an explicit API key" do subject(:adapter) { make_adapter(api_key: "sk-ant-api03-test") } it "returns true" do expect(adapter.authenticated?).to be true end end context "with a stored token" do before do store.save( "access_token" => "sk-ant-oat-stored", "refresh_token" => "rt-stored", "expires_at_ms" => future_ms ) end subject(:adapter) { make_adapter } it "returns true" do expect(adapter.authenticated?).to be true end end context "with no credentials" do subject(:adapter) { make_adapter } it "returns false" do expect(adapter.authenticated?).to be false end end end # ── logout! ─────────────────────────────────────────────────────────────── describe "#logout!" do before do store.save( "access_token" => "sk-ant-oat-live", "refresh_token" => "rt-live", "expires_at_ms" => future_ms ) end subject(:adapter) { make_adapter } it "deletes the token file so authenticated? returns false" do expect(adapter.authenticated?).to be true adapter.logout! expect(adapter.authenticated?).to be false end it "returns nil" do expect(adapter.logout!).to be_nil end it "removes the token store file" do expect(File.exist?(store_path)).to be true adapter.logout! expect(File.exist?(store_path)).to be false end end # ── ensure_token! (lazy refresh) ───────────────────────────────────────── describe "ensure_token! lazy refresh" do before { WebMock.disable_net_connect! } after { WebMock.reset! } context "with an expired token in the store" do before do store.save( "access_token" => "sk-ant-oat-expired", "refresh_token" => "rt-expired", "expires_at_ms" => past_ms ) stub_request(:post, "https://api.anthropic.com/v1/oauth/token") .to_return( status: 200, body: JSON.generate( "access_token" => "sk-ant-oat-new", "refresh_token" => "rt-new", "expires_in" => 3600 ), headers: { "Content-Type" => "application/json" } ) # Stub the actual messages request stub_request(:post, "https://api.anthropic.com/v1/messages") .to_return( status: 200, body: JSON.generate( "id" => "msg_01", "type" => "message", "model" => "claude-sonnet-4-6", "role" => "assistant", "stop_reason" => "end_turn", "content" => [{ "type" => "text", "text" => "Hi" }], "usage" => { "input_tokens" => 5, "output_tokens" => 3 } ), headers: { "Content-Type" => "application/json" } ) end subject(:adapter) { make_adapter } it "refreshes the token before making a chat request" do messages = [Dispatch::Adapter::Message.new( role: "user", content: [Dispatch::Adapter::TextBlock.new(text: "Hello")] )] expect { adapter.chat(messages) }.not_to raise_error # The token store should now hold the refreshed token expect(store.load["access_token"]).to eq("sk-ant-oat-new") end end context "with a non-expired token" do before do store.save( "access_token" => "sk-ant-oat-valid", "refresh_token" => "rt-valid", "expires_at_ms" => future_ms ) end subject(:adapter) { make_adapter } it "does not refresh when the token is still valid" do expect(Dispatch::Adapter::Claude::OAuth).not_to receive(:refresh!) # Call ensure_token! indirectly via a method that uses it adapter.send(:ensure_token!) end end end end