diff --git a/.dockerignore b/.dockerignore index ec33ffa7..9a3d3314 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,3 +18,4 @@ tmp /.idea /.vscode Makefile +.ruby-version diff --git a/.gitignore b/.gitignore index 449764c7..8eb272c0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ test/version_tmp tmp /.idea /.vscode +coverage/ diff --git a/.rspec b/.rspec index b3eb8b49..2e897715 100644 --- a/.rspec +++ b/.rspec @@ -1,2 +1,4 @@ --color ---format documentation \ No newline at end of file +--format documentation +--require spec_helper +--tag ~integration diff --git a/Gemfile b/Gemfile index 00775107..d59de11a 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,8 @@ gem 'activesupport', '~> 4.0' gem 'rack', '~> 1.0' group :test do + gem 'simplecov' + gem 'simplecov_json_formatter' gem 'webmock' end diff --git a/Makefile b/Makefile index 00ee47cd..7a7d274b 100644 --- a/Makefile +++ b/Makefile @@ -7,4 +7,13 @@ bash: build test: build docker compose run cli bundle exec rake -.PHONY: build bash test +integration: build + @echo "Running integration tests..." + @echo "Set DEPLOY_API_URL to point to your running deploy-api instance" + @echo "Set DEPLOY_API_TOKEN if authentication is required" + docker compose run \ + -e DEPLOY_API_URL=${DEPLOY_API_URL} \ + -e DEPLOY_API_TOKEN=${DEPLOY_API_TOKEN} \ + cli bundle exec rspec --tag integration + +.PHONY: build bash test integration diff --git a/Rakefile b/Rakefile index 1a4d0407..0770f4cc 100644 --- a/Rakefile +++ b/Rakefile @@ -1,4 +1,12 @@ require 'bundler/gem_tasks' +require 'rspec/core/rake_task' +require 'rubocop/rake_task' -require 'aptible/tasks' -Aptible::Tasks.load_tasks +RSpec::Core::RakeTask.new(:spec) do |spec| + spec.pattern = 'spec/**/*_spec.rb' + spec.rspec_opts = '--exclude-pattern spec/integration/**/*_spec.rb' +end + +RuboCop::RakeTask.new + +task default: [:spec, :rubocop] diff --git a/lib/aptible/cli/agent.rb b/lib/aptible/cli/agent.rb index 2cb4da8d..1aa1f769 100644 --- a/lib/aptible/cli/agent.rb +++ b/lib/aptible/cli/agent.rb @@ -26,6 +26,7 @@ require_relative 'helpers/date_helpers' require_relative 'helpers/s3_log_helpers' require_relative 'helpers/maintenance' +require_relative 'helpers/ai_token' require_relative 'helpers/aws_account' require_relative 'subcommands/apps' @@ -46,6 +47,7 @@ require_relative 'subcommands/metric_drain' require_relative 'subcommands/maintenance' require_relative 'subcommands/backup_retention_policy' +require_relative 'subcommands/ai_tokens' require_relative 'subcommands/aws_accounts' module Aptible @@ -76,6 +78,7 @@ class Agent < Thor include Subcommands::MetricDrain include Subcommands::Maintenance include Subcommands::BackupRetentionPolicy + include Subcommands::AiTokens include Subcommands::AwsAccounts # Forward return codes on failures. diff --git a/lib/aptible/cli/helpers/ai_token.rb b/lib/aptible/cli/helpers/ai_token.rb new file mode 100644 index 00000000..0e572885 --- /dev/null +++ b/lib/aptible/cli/helpers/ai_token.rb @@ -0,0 +1,90 @@ +module Aptible + module CLI + module Helpers + module AiToken + include Helpers::Token + + def ensure_ai_token(account, id) + ai_tokens = account.ai_tokens.select { |t| t.id.to_s == id.to_s } + + raise Thor::Error, "AI token #{id} not found or access denied" if ai_tokens.empty? + + ai_tokens.first + rescue HyperResource::ClientError => e + if e.response.status == 404 + raise Thor::Error, "AI token #{id} not found or access denied" + else + raise Thor::Error, "Failed to retrieve token: #{e.message}" + end + end + + def create_ai_token(account, opts) + ai_token = account.create_ai_token!(opts) + + # Log full HAL response in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' + begin + CLI.logger.warn "POST create response: #{JSON.pretty_generate(ai_token.body)}" + rescue StandardError + CLI.logger.warn "POST create response: #{ai_token.body.inspect}" + end + end + + Formatter.render(Renderer.current) do |root| + root.object do |node| + ResourceFormatter.inject_ai_token(node, ai_token, account) + + # Include the token value on creation if present + token_value = ai_token.attributes['token'] + node.value('token', token_value) if token_value + end + end + + # Warn about token value and gateway URL if present + token_value = ai_token.attributes['token'] + gateway_url = ai_token.attributes['gateway_url'] + if token_value + CLI.logger.warn "\nSave the token value now - it will not be shown again!" + CLI.logger.warn "Use this token to authenticate requests to: #{gateway_url}" if gateway_url + end + + ai_token + rescue HyperResource::ClientError, HyperResource::ServerError => e + # Log response body in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.respond_to?(:response) && e.response + begin + body = e.response.body + parsed_body = body.is_a?(String) ? JSON.parse(body) : body + CLI.logger.warn "POST create error response (#{e.response.status}): #{JSON.pretty_generate(parsed_body)}" + rescue StandardError + CLI.logger.warn "POST create error response (#{e.response.status}): #{e.response.body.inspect}" + end + end + + # Extract clean error message from response + error_message = if e.respond_to?(:body) && e.body.is_a?(Hash) + e.body['error'] || e.message + elsif e.respond_to?(:response) && e.response&.status + "Failed to create token: HTTP #{e.response.status}" + else + e.message + end + raise Thor::Error, error_message + rescue HyperResource::ResponseError => e + # Log response body in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response + begin + body = e.response.body + parsed_body = body.is_a?(String) ? JSON.parse(body) : body + CLI.logger.warn "POST create response error (#{e.response.status}): #{JSON.pretty_generate(parsed_body)}" + rescue StandardError + CLI.logger.warn "POST create response error (#{e.response.status}): #{e.response.body.inspect}" + end + end + + raise Thor::Error, "Failed to create token: HTTP #{e.response&.status || 'unknown error'}" + end + end + end + end +end diff --git a/lib/aptible/cli/resource_formatter.rb b/lib/aptible/cli/resource_formatter.rb index c8230a54..2453971d 100644 --- a/lib/aptible/cli/resource_formatter.rb +++ b/lib/aptible/cli/resource_formatter.rb @@ -262,6 +262,108 @@ def inject_metric_drain(node, metric_drain, account) attach_account(node, account) end + def inject_ai_token(node, ai_token, account, include_display: false) + require 'base64' + + node.value('id', ai_token.id) + + # Decode the note from URL-safe base64 (encrypted at rest, decoded here for display) + encoded_note = ai_token.attributes['note'] rescue nil + note = if encoded_note + begin + Base64.urlsafe_decode64(encoded_note) + rescue ArgumentError + encoded_note # Fall back to raw value if decoding fails + end + end + node.value('note', note) if note + + # Check blocked status (use attributes hash for HyperResource compatibility) + is_blocked = ai_token.attributes['blocked'] rescue false + + # Display field is only used for list view (grouped_keyed_list) + if include_display + # Determine status: revoked if blocked, otherwise active + status = is_blocked ? 'REVOKED' : 'ACTIVE' + + # Format: "ID ACTIVE note" or "ID REVOKED note" + # Pad ACTIVE with 2 extra spaces to align with REVOKED (7 chars + 1 space = 8) + status_padded = status == 'ACTIVE' ? 'ACTIVE ' : 'REVOKED ' + display_note = note || '' + node.value('display', "#{ai_token.id} #{status_padded}#{display_note}") + end + + node.value('created_at', ai_token.created_at) + + # Optional fields - only include if present (use attributes hash for HyperResource compatibility) + updated_at = ai_token.attributes['updated_at'] rescue nil + node.value('updated_at', updated_at) if updated_at + + last_used_at = ai_token.attributes['last_used_at'] rescue nil + node.value('last_used_at', last_used_at) if last_used_at + + # Show status in detail view + node.value('status', is_blocked ? 'REVOKED' : 'ACTIVE') + + # Include gateway URL if present + gateway_url = ai_token.attributes['gateway_url'] rescue nil + node.value('gateway_url', gateway_url) if gateway_url + + # Include actor tracking info if present (encrypted at rest in LLM Gateway, decrypted by deploy-api) + # Show user (on whose behalf) details + created_by_user_id = ai_token.attributes['created_by_user_id'] rescue nil + created_by_actor_id = ai_token.attributes['created_by_actor_id'] rescue nil + + node.value('created_by_user_id', created_by_user_id) if created_by_user_id + + created_by_user_name = ai_token.attributes['created_by_user_name'] rescue nil + node.value('created_by_user_name', created_by_user_name) if created_by_user_name + + created_by_user_email = ai_token.attributes['created_by_user_email'] rescue nil + node.value('created_by_user_email', created_by_user_email) if created_by_user_email + + # Only show actor (who performed) details if different from user (impersonation case) + if created_by_actor_id && created_by_actor_id != created_by_user_id + node.value('created_by_actor_id', created_by_actor_id) + + created_by_actor_name = ai_token.attributes['created_by_actor_name'] rescue nil + node.value('created_by_actor_name', created_by_actor_name) if created_by_actor_name + + created_by_actor_email = ai_token.attributes['created_by_actor_email'] rescue nil + node.value('created_by_actor_email', created_by_actor_email) if created_by_actor_email + end + + # Show revoked_by user details + revoked_by_user_id = ai_token.attributes['revoked_by_user_id'] rescue nil + revoked_by_actor_id = ai_token.attributes['revoked_by_actor_id'] rescue nil + + if revoked_by_user_id + node.value('revoked_by_user_id', revoked_by_user_id) + + revoked_by_user_name = ai_token.attributes['revoked_by_user_name'] rescue nil + node.value('revoked_by_user_name', revoked_by_user_name) if revoked_by_user_name + + revoked_by_user_email = ai_token.attributes['revoked_by_user_email'] rescue nil + node.value('revoked_by_user_email', revoked_by_user_email) if revoked_by_user_email + end + + # Only show revoked_by actor details if different from user (impersonation case) + if revoked_by_actor_id && revoked_by_actor_id != revoked_by_user_id + node.value('revoked_by_actor_id', revoked_by_actor_id) + + revoked_by_actor_name = ai_token.attributes['revoked_by_actor_name'] rescue nil + node.value('revoked_by_actor_name', revoked_by_actor_name) if revoked_by_actor_name + + revoked_by_actor_email = ai_token.attributes['revoked_by_actor_email'] rescue nil + node.value('revoked_by_actor_email', revoked_by_actor_email) if revoked_by_actor_email + end + + revoked_at = ai_token.attributes['revoked_at'] rescue nil + node.value('revoked_at', revoked_at) if revoked_at + + attach_account(node, account) if account + end + def inject_maintenance( node, command_prefix, diff --git a/lib/aptible/cli/subcommands/ai_tokens.rb b/lib/aptible/cli/subcommands/ai_tokens.rb new file mode 100644 index 00000000..30b692fa --- /dev/null +++ b/lib/aptible/cli/subcommands/ai_tokens.rb @@ -0,0 +1,270 @@ +module Aptible + module CLI + module Subcommands + module AiTokens + def self.included(thor) + thor.class_eval do + include Helpers::Token + include Helpers::Environment + include Helpers::AiToken + include Helpers::Telemetry + + desc 'ai:tokens:create [--environment ENVIRONMENT_HANDLE] [--note NOTE]', + 'Create a new AI token' + option :environment, aliases: '--env', desc: 'Environment to create the token in' + option :note, type: :string, desc: 'Optional note to describe the token (max 256 chars)' + define_method 'ai:tokens:create' do + telemetry(__method__, options) + + account = ensure_environment(options) + + opts = {} + if options[:note] + # URL-safe base64 encode the note for safe transport to deploy-api + # deploy-api will validate and encrypt it before storing in LLM Gateway + require 'base64' + opts[:note] = Base64.urlsafe_encode64(options[:note], padding: true) + end + + create_ai_token(account, opts) + end + + desc 'ai:tokens:list [--environment ENVIRONMENT_HANDLE]', + 'List all AI tokens' + option :environment, aliases: '--env', desc: 'Environment to list tokens from' + define_method 'ai:tokens:list' do + telemetry(__method__, options) + + Formatter.render(Renderer.current) do |root| + root.grouped_keyed_list( + { 'environment' => 'handle' }, + 'display' + ) do |node| + accounts = scoped_environments(options) + + accounts.each do |account| + begin + # Fetch tokens collection + tokens = account.ai_tokens + next unless tokens # Skip if no tokens available + + # Log full HAL response in debug mode (single API call returns all tokens) + if ENV['APTIBLE_DEBUG'] == 'DEBUG' + begin + tokens_array = tokens.map(&:body) + CLI.logger.warn "GET /accounts/#{account.id}/ai_tokens response: #{JSON.pretty_generate(tokens_array)}" + rescue StandardError + CLI.logger.warn "GET /accounts/#{account.id}/ai_tokens response: #{tokens.map(&:body).inspect}" + end + end + + tokens.each do |ai_token| + node.object do |n| + ResourceFormatter.inject_ai_token(n, ai_token, account, include_display: true) + end + end + rescue HyperResource::ClientError => e + error_message = extract_api_error(e) + + # Log response body in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response + CLI.logger.warn "GET list error response (#{e.response.status}): #{error_message}" + end + + # Skip if endpoint not available for this account + if e.response&.status == 404 + next + elsif e.response&.status == 401 || e.response&.status == 403 + raise Thor::Error, error_message + else + raise Thor::Error, "Failed to list tokens: #{error_message}" + end + rescue HyperResource::ResponseError => e + error_message = extract_api_error(e) + + # Log response body in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response + CLI.logger.warn "GET list response error (#{e.response.status}): #{error_message}" + end + + raise Thor::Error, "Failed to list tokens: #{error_message}" + end + end + end + end + end + + desc 'ai:tokens:show ID', 'Show details of an AI token' + define_method 'ai:tokens:show' do |id| + telemetry(__method__, options.merge(id: id)) + + # GET /ai_tokens/:id via HAL + # Must set root URL explicitly for the request to work + api_root = Aptible::Api.configuration.root_url + ai_token = Aptible::Api::AiToken.new( + root: api_root, + token: fetch_token + ) + ai_token.href = "#{api_root}/ai_tokens/#{id}" + + begin + ai_token = ai_token.get + + # Log full HAL response in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' + begin + CLI.logger.warn "GET show response: #{JSON.pretty_generate(ai_token.body)}" + rescue StandardError + CLI.logger.warn "GET show response: #{ai_token.body.inspect}" + end + end + + # Get account from token's link if available + account = nil + if ai_token.links && ai_token.links.account + begin + account = Aptible::Api::Account.new( + token: fetch_token + ).find_by_url(ai_token.links.account.href) + rescue StandardError + # If we can't fetch the account, continue without it + account = nil + end + end + + Formatter.render(Renderer.current) do |root| + root.object do |node| + ResourceFormatter.inject_ai_token(node, ai_token, account) + end + end + rescue HyperResource::ClientError => e + error_message = extract_api_error(e) + + # Log response body in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response + CLI.logger.warn "GET show error response (#{e.response.status}): #{error_message}" + end + + if e.response&.status == 404 + raise Thor::Error, "AI token #{id} not found or access denied" + elsif e.response&.status == 401 || e.response&.status == 403 + raise Thor::Error, error_message + else + raise Thor::Error, "Failed to retrieve token: #{error_message}" + end + rescue HyperResource::ResponseError => e + error_message = extract_api_error(e) + + # Log response body in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response + CLI.logger.warn "GET show response error (#{e.response.status}): #{error_message}" + end + + raise Thor::Error, "Failed to retrieve token: #{error_message}" + end + end + + desc 'ai:tokens:revoke ID', 'Revoke an AI token' + define_method 'ai:tokens:revoke' do |id| + telemetry(__method__, options.merge(id: id)) + + # First, fetch the token to verify it exists and get a proper resource + api_root = Aptible::Api.configuration.root_url + url = "#{api_root}/ai_tokens/#{id}" + + begin + ai_token = Aptible::Api::AiToken.new(token: fetch_token) + .find_by_url(url) + raise Thor::Error, "AI token #{id} not found" if ai_token.nil? + + # Log full HAL response in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' + begin + CLI.logger.warn "GET response: #{JSON.pretty_generate(ai_token.body)}" + rescue StandardError + CLI.logger.warn "GET response: #{ai_token.body.inspect}" + end + end + + # Check if already revoked before attempting DELETE + if ai_token.blocked + raise Thor::Error, 'Token has already been revoked' + end + + revoked_token = ai_token.delete + + # Log DELETE response in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' + if ai_token.response + response_body = ai_token.response.body + if response_body && !response_body.empty? + begin + CLI.logger.warn "DELETE response (#{ai_token.response.status}): #{JSON.pretty_generate(JSON.parse(response_body))}" + rescue StandardError + CLI.logger.warn "DELETE response (#{ai_token.response.status}): #{response_body.inspect}" + end + else + CLI.logger.warn "DELETE response (#{ai_token.response.status}): " + end + end + end + + # Render the revoked token (supports JSON output format) + Formatter.render(Renderer.current) do |root| + root.object do |node| + # Get account from token's link if available + account = nil + if revoked_token&.links && revoked_token.links.account + begin + account = Aptible::Api::Account.new( + token: fetch_token + ).find_by_url(revoked_token.links.account.href) + rescue StandardError + # If we can't fetch the account, continue without it + account = nil + end + end + + ResourceFormatter.inject_ai_token(node, revoked_token || ai_token, account) + end + end + + CLI.logger.info "\nAI token revoked successfully" + rescue HyperResource::ClientError => e + error_message = extract_api_error(e) + + # Log response body in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response + CLI.logger.warn "DELETE error response (#{e.response.status}): #{error_message}" + end + + if e.response&.status == 404 + raise Thor::Error, "AI token #{id} not found or access denied" + elsif e.response&.status == 401 || e.response&.status == 403 + raise Thor::Error, error_message + else + raise Thor::Error, "Failed to revoke token: #{error_message}" + end + end + # Note: HyperResource::ResponseError from empty 204 body is caught by + # Aptible::Resource::Base#delete (returns nil), so delete succeeds silently + end + + private + + # Extract error message from HyperResource error response + def extract_api_error(error) + return error.message unless error.response + + body = error.response.body + parsed = body.is_a?(String) ? JSON.parse(body) : body + parsed['error'] || parsed.to_s + rescue StandardError + error.message + end + end + end + end + end + end +end diff --git a/spec/aptible/cli/subcommands/ai_tokens_spec.rb b/spec/aptible/cli/subcommands/ai_tokens_spec.rb new file mode 100644 index 00000000..0b3afe9f --- /dev/null +++ b/spec/aptible/cli/subcommands/ai_tokens_spec.rb @@ -0,0 +1,175 @@ +require 'spec_helper' + +describe Aptible::CLI::Agent do + let(:account) { Fabricate(:account) } + let(:token) { double('token') } + + before { allow(subject).to receive(:fetch_token).and_return(token) } + + describe '#ai:tokens:list' do + let!(:ai_token) do + Fabricate(:ai_token, name: 'test-token', account: account) + end + + before do + allow(subject).to receive(:scoped_environments).with({}).and_return([account]) + end + + it 'lists AI tokens for an account' do + expect { subject.send('ai:tokens:list') }.not_to raise_error + end + + it 'lists AI tokens across multiple accounts' do + other_account = Fabricate(:account) + Fabricate(:ai_token, name: 'test-token-2', account: other_account) + + allow(subject).to receive(:scoped_environments).with({}) + .and_return([account, other_account]) + + expect { subject.send('ai:tokens:list') }.not_to raise_error + end + + it 'skips accounts when endpoint returns 404' do + error_response = double('error_response', status: 404, body: 'Not Found') + error = HyperResource::ClientError.new('Not Found', response: error_response) + + allow(account).to receive(:ai_tokens).and_raise(error) + + expect { subject.send('ai:tokens:list') }.not_to raise_error + end + end + + describe '#ai:tokens:create' do + let(:created_token) do + Fabricate(:ai_token, name: 'new-token', account: account) + end + + before do + allow(subject).to receive(:ensure_environment).and_return(account) + end + + it 'creates an AI token with a note' do + encoded_note = Base64.urlsafe_encode64('new-token', padding: true) + expect(account).to receive(:create_ai_token!) + .with(note: encoded_note).and_return(created_token) + + subject.options = { note: 'new-token' } + expect { subject.send('ai:tokens:create') }.not_to raise_error + + expect(captured_logs).to include('Save the token value now') + end + + it 'creates an AI token without a note' do + expect(account).to receive(:create_ai_token!) + .with({}).and_return(created_token) + + subject.options = {} + subject.send('ai:tokens:create') + + expect(captured_logs).to include('Save the token value now') + end + + it 'warns user to save token value if present' do + token_with_value = Fabricate(:ai_token, name: 'new-token', account: account) + allow(token_with_value).to receive(:attributes) + .and_return({ 'token' => 'sk-secret-value', 'gateway_url' => 'https://gateway.example.com' }) + + encoded_note = Base64.urlsafe_encode64('new-token', padding: true) + expect(account).to receive(:create_ai_token!) + .with(note: encoded_note).and_return(token_with_value) + + subject.options = { note: 'new-token' } + subject.send('ai:tokens:create') + + expect(captured_logs).to include('Save the token value now - it will not be shown again!') + expect(captured_logs).to include('Use this token to authenticate requests to: https://gateway.example.com') + end + end + + describe '#ai:tokens:show' do + let(:ai_token_resource) { double('ai_token_resource') } + let(:token_id) { 'sk-test-token-12345' } + let(:token_response) do + Fabricate(:ai_token, id: token_id, name: 'test-token', account: nil) + end + + before do + allow(Aptible::Api::AiToken).to receive(:new) + .with(root: 'https://app-98582.aptible-test-leeroy.com', token: token) + .and_return(ai_token_resource) + allow(ai_token_resource).to receive(:href=) + end + + it 'shows an AI token successfully' do + allow(ai_token_resource).to receive(:get).and_return(token_response) + + expect { subject.send('ai:tokens:show', token_id) }.not_to raise_error + + expect(ai_token_resource).to have_received(:href=).with("https://app-98582.aptible-test-leeroy.com/ai_tokens/#{token_id}") + expect(ai_token_resource).to have_received(:get) + end + + it 'raises an error if the token is not found (404)' do + error_response = double('error_response', status: 404, body: 'Not Found') + error = HyperResource::ClientError.new('Not Found', response: error_response) + allow(ai_token_resource).to receive(:get).and_raise(error) + + expect { subject.send('ai:tokens:show', 'nonexistent') } + .to raise_error(Thor::Error, /AI token nonexistent not found or access denied/) + end + + it 'raises an error on other client errors' do + error_response = double('error_response', status: 500, body: 'Internal Server Error') + error = HyperResource::ClientError.new('Internal Server Error', response: error_response) + allow(ai_token_resource).to receive(:get).and_raise(error) + + expect { subject.send('ai:tokens:show', token_id) } + .to raise_error(Thor::Error, /Failed to retrieve token/) + end + end + + describe '#ai:tokens:revoke' do + let(:ai_token_resource) { double('ai_token_resource') } + let(:token_id) { 'sk-test-token-12345' } + let(:token_obj) { double('ai_token', blocked: false, delete: nil) } + + before do + allow(Aptible::Api::AiToken).to receive(:new) + .with(token: token) + .and_return(ai_token_resource) + end + + it 'revokes an AI token successfully' do + url = "https://app-98582.aptible-test-leeroy.com/ai_tokens/#{token_id}" + allow(ai_token_resource).to receive(:find_by_url).with(url).and_return(token_obj) + allow(token_obj).to receive(:delete) + + subject.send('ai:tokens:revoke', token_id) + + expect(ai_token_resource).to have_received(:find_by_url).with(url) + expect(token_obj).to have_received(:delete) + expect(captured_logs).to include('AI token revoked successfully') + end + + it 'raises an error if the token is not found (404)' do + url = "https://app-98582.aptible-test-leeroy.com/ai_tokens/nonexistent" + error_response = double('error_response', status: 404, body: 'Not Found') + error = HyperResource::ClientError.new('Not Found', response: error_response) + allow(ai_token_resource).to receive(:find_by_url).with(url).and_raise(error) + + expect { subject.send('ai:tokens:revoke', 'nonexistent') } + .to raise_error(Thor::Error, /AI token nonexistent not found or access denied/) + end + + it 'raises an error on other client errors' do + url = "https://app-98582.aptible-test-leeroy.com/ai_tokens/#{token_id}" + allow(ai_token_resource).to receive(:find_by_url).with(url).and_return(token_obj) + error_response = double('error_response', status: 500, body: 'Internal Server Error') + error = HyperResource::ClientError.new('Internal Server Error', response: error_response) + allow(token_obj).to receive(:delete).and_raise(error) + + expect { subject.send('ai:tokens:revoke', token_id) } + .to raise_error(Thor::Error, /Failed to revoke token/) + end + end +end diff --git a/spec/fabricators/account_fabricator.rb b/spec/fabricators/account_fabricator.rb index eff3dde4..63015ecb 100644 --- a/spec/fabricators/account_fabricator.rb +++ b/spec/fabricators/account_fabricator.rb @@ -29,6 +29,7 @@ def each_certificate(&block) log_drains { [] } metric_drains { [] } backup_retention_policies { [] } + ai_tokens { [] } created_at { Time.now } links do |attrs| hash = { diff --git a/spec/fabricators/ai_token_fabricator.rb b/spec/fabricators/ai_token_fabricator.rb new file mode 100644 index 00000000..6096c75c --- /dev/null +++ b/spec/fabricators/ai_token_fabricator.rb @@ -0,0 +1,40 @@ +class StubAiToken < OpenStruct + def attributes + { + 'id' => id, + 'name' => name, + 'token' => token, + 'created_at' => created_at + } + end + + # Provide handle method for grouped list formatter + def handle + account ? account.handle : 'aptible' + end +end + +Fabricator(:ai_token, from: :stub_ai_token) do + id { sequence(:ai_token_id) } + name 'test-ai-token' + token 'sk-test-token-12345' + created_at { Time.now } + account + links do |attrs| + hash = {} + if attrs[:account] + hash[:account] = OpenStruct.new( + href: "/accounts/#{attrs[:account].id}" + ) + end + OpenStruct.new(hash) + end + + after_create do |ai_token| + if ai_token.account + ai_token.account.ai_tokens ||= [] + ai_token.account.ai_tokens << ai_token + end + end +end + diff --git a/spec/integration/ai_tokens_integration_spec.rb b/spec/integration/ai_tokens_integration_spec.rb new file mode 100644 index 00000000..95cb1b3f --- /dev/null +++ b/spec/integration/ai_tokens_integration_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'net/http' +require 'json' + +# Integration tests - require a running deploy-api instance +# +# Prerequisites: +# 1. Running deploy-api at DEPLOY_API_URL (e.g., http://localhost:3000) +# 2. Valid Aptible API token in DEPLOY_API_TOKEN +# 3. Target environment handle in TEST_ENVIRONMENT (e.g., "test-env") +# 4. LLM Gateway configured in deploy-api (or mocked) +# +# Run with: +# DEPLOY_API_URL=http://localhost:3000 \ +# DEPLOY_API_TOKEN=your_token \ +# TEST_ENVIRONMENT=your-env-handle \ +# bundle exec rspec --tag integration +# +describe 'AI Tokens Integration', :integration do + before(:all) do + @api_url = ENV['DEPLOY_API_URL'] + @api_token = ENV['DEPLOY_API_TOKEN'] + @test_env = ENV['TEST_ENVIRONMENT'] + + skip 'Set DEPLOY_API_URL to run integration tests' unless @api_url + skip 'Set DEPLOY_API_TOKEN to run integration tests' unless @api_token + skip 'Set TEST_ENVIRONMENT to run integration tests' unless @test_env + + # Check if API is reachable + begin + uri = URI.parse(@api_url) + response = Net::HTTP.get_response(uri) + unless response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection) + skip "Deploy API not reachable at #{@api_url}" + end + rescue StandardError => e + skip "Deploy API not reachable at #{@api_url}: #{e.message}" + end + end + + let(:api_url) { @api_url } + let(:api_token) { @api_token } + let(:test_env) { @test_env } + let(:created_token_ids) { @created_token_ids ||= [] } + + # Clean up any tokens created during tests + after(:all) do + next unless @created_token_ids && @api_token + + @created_token_ids.each do |token_id| + # Best effort cleanup - don't fail if token already deleted + system("DEPLOY_API_URL=#{@api_url} DEPLOY_API_TOKEN=#{@api_token} " \ + "bundle exec aptible ai:tokens:revoke #{token_id} 2>/dev/null") + rescue StandardError + # Ignore errors during cleanup + end + end + + # Helper to make direct API calls for verification + def make_api_request(method, path, body = nil) + uri = URI.parse("#{api_url}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + + request = case method + when :get then Net::HTTP::Get.new(uri) + when :post then Net::HTTP::Post.new(uri) + when :delete then Net::HTTP::Delete.new(uri) + end + + request['Authorization'] = "Bearer #{api_token}" + request['Accept'] = 'application/hal+json' + request['Content-Type'] = 'application/json' if body + request.body = body.to_json if body + + http.request(request) + end + + describe 'ai:tokens:list' do + it 'successfully connects to deploy-api and lists tokens' do + # Get accounts first to find an account ID + response = make_api_request(:get, '/accounts') + expect(response.code).to eq('200'), "Failed to fetch accounts: #{response.body}" + + data = JSON.parse(response.body) + accounts = data.dig('_embedded', 'accounts') + expect(accounts).not_to be_empty, 'No accounts found' + + account_id = accounts.first['id'] + + # List tokens for this account + response = make_api_request(:get, "/accounts/#{account_id}/ai_tokens") + expect(response.code).to eq('200'), "Failed to list tokens: #{response.body}" + + data = JSON.parse(response.body) + expect(data).to have_key('_embedded') + expect(data['_embedded']).to have_key('ai_tokens') + # The list might be empty, that's OK + expect(data['_embedded']['ai_tokens']).to be_an(Array) + end + end + + describe 'ai:tokens:create' do + it 'creates a new AI token via API' do + # Get accounts to find an account ID + response = make_api_request(:get, '/accounts') + expect(response.code).to eq('200') + + data = JSON.parse(response.body) + account_id = data.dig('_embedded', 'accounts', 0, 'id') + expect(account_id).not_to be_nil + + # Create a token + token_name = "integration-test-#{Time.now.to_i}" + response = make_api_request(:post, "/accounts/#{account_id}/ai_tokens", { name: token_name }) + + expect(response.code).to eq('201'), "Failed to create token: #{response.body}" + + data = JSON.parse(response.body) + expect(data['name']).to eq(token_name) + expect(data['id']).not_to be_nil + expect(data['token']).not_to be_nil # Should include token value on creation + expect(data['_type']).to eq('ai_token') + + # Track for cleanup + @created_token_ids ||= [] + @created_token_ids << data['id'] + end + end + + describe 'ai:tokens:show' do + it 'retrieves details of a specific token' do + # First create a token + response = make_api_request(:get, '/accounts') + account_id = JSON.parse(response.body).dig('_embedded', 'accounts', 0, 'id') + + create_response = make_api_request(:post, "/accounts/#{account_id}/ai_tokens", + { name: "show-test-#{Time.now.to_i}" }) + token_data = JSON.parse(create_response.body) + token_id = token_data['id'] + + @created_token_ids ||= [] + @created_token_ids << token_id + + # Now retrieve it + response = make_api_request(:get, "/ai_tokens/#{token_id}") + expect(response.code).to eq('200'), "Failed to get token: #{response.body}" + + data = JSON.parse(response.body) + expect(data['id']).to eq(token_id) + expect(data['token']).to be_nil # Should NOT include token value on show + end + end + + describe 'ai:tokens:revoke' do + it 'revokes an existing token' do + # First create a token + response = make_api_request(:get, '/accounts') + account_id = JSON.parse(response.body).dig('_embedded', 'accounts', 0, 'id') + + create_response = make_api_request(:post, "/accounts/#{account_id}/ai_tokens", + { name: "revoke-test-#{Time.now.to_i}" }) + token_data = JSON.parse(create_response.body) + token_id = token_data['id'] + + # Revoke it + response = make_api_request(:delete, "/ai_tokens/#{token_id}") + expect(response.code).to eq('204'), "Failed to revoke token: #{response.body}" + + # Verify it's gone (should return 404) + response = make_api_request(:get, "/ai_tokens/#{token_id}") + expect(response.code).to eq('404'), "Token should not be found after revocation" + end + end + + describe 'authorization' do + it 'rejects requests without a valid token' do + response = make_api_request(:get, '/accounts') + account_id = JSON.parse(response.body).dig('_embedded', 'accounts', 0, 'id') + + # Try to create without proper auth by temporarily using bad token + uri = URI.parse("#{api_url}/accounts/#{account_id}/ai_tokens") + http = Net::HTTP.new(uri.host, uri.port) + request = Net::HTTP::Post.new(uri) + request['Authorization'] = 'Bearer invalid-token' + request['Content-Type'] = 'application/json' + request.body = { name: 'unauthorized-test' }.to_json + + response = http.request(request) + expect(response.code).to eq('401'), 'Should reject invalid token' + end + end +end + diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d1bcf9d1..a61e1fb7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,21 @@ Bundler.require :development +require 'simplecov' +require 'simplecov_json_formatter' + +# Configure SimpleCov for both HTML and JSON output +SimpleCov.start do + add_filter '/spec/' + add_filter '/vendor/' + + # Generate both HTML (for human viewing) and JSON (for CI/tooling) + formatter SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::JSONFormatter + ]) +end + # Load shared spec files Dir["#{File.dirname(__FILE__)}/shared/**/*.rb"].each do |file| require file