diff --git a/.bundle/config b/.bundle/config index 363c858e..9b3989a3 100644 --- a/.bundle/config +++ b/.bundle/config @@ -1,5 +1 @@ ---- -BUNDLE_PATH: "/home/runner/work/client-ruby/client-ruby/vendor/bundle" -BUNDLE_IGNORE_FUNDING_REQUESTS: "true" -BUNDLE_JOBS: "4" -BUNDLE_DEPLOYMENT: "true" +BUNDLE_PATH: "./vendor/bundle" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2f948f9e..1d8d969d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,17 +6,6 @@ on: ref: required: true type: string - secrets: - BASE_URL: - required: false - AUTH_TOKEN: - required: false - JWT_KEY: - required: false - CLIENT_ID: - required: false - CLIENT_SECRET: - required: false defaults: run: @@ -49,12 +38,6 @@ jobs: - name: Run Tests run: bundle exec rake test - env: - BASE_URL: ${{ secrets.BASE_URL }} - AUTH_TOKEN: ${{ secrets.AUTH_TOKEN }} - JWT_KEY: ${{ secrets.JWT_KEY }} - CLIENT_ID: ${{ secrets.CLIENT_ID }} - CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }} - name: Upload Results uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/Rakefile b/Rakefile index a44ac780..9dcfdecf 100644 --- a/Rakefile +++ b/Rakefile @@ -28,7 +28,7 @@ end Rake::TestTask.new(:test) do |task| task.libs << 'lib' << 'test' << 'spec' task.pattern = %w[test/**/*_test.rb spec/**/*_spec.rb] - task.verbose = true + task.verbose = false end task default: :test diff --git a/devbox.json b/devbox.json index be4ed855..0917b215 100644 --- a/devbox.json +++ b/devbox.json @@ -7,7 +7,7 @@ "shell": { "init_hook": [ "lefthook install", - "bundle install" + "bundle install --quiet" ], "scripts": { "format": [ diff --git a/etc/docker-compose.yaml b/etc/docker-compose.yaml new file mode 100644 index 00000000..238af6f9 --- /dev/null +++ b/etc/docker-compose.yaml @@ -0,0 +1,89 @@ +services: + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_USER: root + POSTGRES_PASSWORD: postgres + networks: + - storage + healthcheck: + test: [ "CMD-SHELL", "pg_isready", "-d", "db_prod" ] + interval: 10s + timeout: 60s + retries: 5 + start_period: 10s + volumes: + - data:/var/lib/postgresql/data:rw + + zitadel-init: + restart: 'no' + networks: + - storage + image: 'ghcr.io/zitadel/zitadel:latest' + command: 'init --config /example-zitadel-config.yaml --config /example-zitadel-secrets.yaml' + depends_on: + db: + condition: 'service_healthy' + volumes: + - './example-zitadel-config.yaml:/example-zitadel-config.yaml:ro' + - './example-zitadel-secrets.yaml:/example-zitadel-secrets.yaml:ro' + - './zitadel_output:/var/zitadel_output:rw' + + zitadel-setup: + restart: 'no' + networks: + - storage + image: 'ghcr.io/zitadel/zitadel:latest-debug' + user: root + entrypoint: '/bin/bash' + command: [ "-c", "/app/zitadel setup --config /example-zitadel-config.yaml --config /example-zitadel-secrets.yaml --steps /example-zitadel-init-steps.yaml --masterkey \"my_test_masterkey_0123456789ABEF\" && echo \"--- ZITADEL SETUP COMPLETE ---\" && echo \"Personal Access Token (PAT) will be in ./zitadel_output/pat.txt on your host.\" && echo \"Service Account Key will be in ./zitadel_output/sa-key.json on your host.\" && echo \"OAuth Client ID and Secret will be in 'zitadel' service logs (grep for 'Application created').\"" ] + environment: + - PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/app + depends_on: + zitadel-init: + condition: 'service_completed_successfully' + restart: false + volumes: + - './zitadel_output:/var/zitadel_output:rw' + - './example-zitadel-config.yaml:/example-zitadel-config.yaml:ro' + - './example-zitadel-secrets.yaml:/example-zitadel-secrets.yaml:ro' + - './example-zitadel-init-steps.yaml:/example-zitadel-init-steps.yaml:ro' + + zitadel: + restart: 'unless-stopped' + networks: + - backend + - storage + image: 'ghcr.io/zitadel/zitadel:latest' + command: > + start --config /example-zitadel-config.yaml + --config /example-zitadel-secrets.yaml + --masterkey my_test_masterkey_0123456789ABEF + depends_on: + zitadel-setup: + condition: 'service_completed_successfully' + restart: true + volumes: + - './example-zitadel-config.yaml:/example-zitadel-config.yaml:ro' + - './example-zitadel-secrets.yaml:/example-zitadel-secrets.yaml:ro' + - './zitadel_output:/var/zitadel_output:rw' + ports: + - "8099:8080" + healthcheck: + test: [ + "CMD", "/app/zitadel", "ready", + "--config", "/example-zitadel-config.yaml", + "--config", "/example-zitadel-secrets.yaml" + ] + interval: 10s + timeout: 60s + retries: 5 + start_period: 10s + +networks: + storage: { } + backend: { } + +volumes: + data: { } diff --git a/etc/example-zitadel-config.yaml b/etc/example-zitadel-config.yaml new file mode 100644 index 00000000..7635f8a2 --- /dev/null +++ b/etc/example-zitadel-config.yaml @@ -0,0 +1,17 @@ +ExternalSecure: false +ExternalDomain: localhost +ExternalPort: 8080 +TLS.Enabled: false +Database: + postgres: + Host: 'db' + Port: 5432 + Database: zitadel + User.SSL.Mode: 'disable' + Admin.SSL.Mode: 'disable' +OIDC: + DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" + DefaultLogoutURLV2: "/ui/v2/login/logout?post_logout_redirect=" +SAML.DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" +LogStore.Access.Stdout.Enabled: true +DefaultInstance.LoginPolicy.MfaInitSkipLifetime: "0s" diff --git a/etc/example-zitadel-init-steps.yaml b/etc/example-zitadel-init-steps.yaml new file mode 100644 index 00000000..d697ad8f --- /dev/null +++ b/etc/example-zitadel-init-steps.yaml @@ -0,0 +1,35 @@ +FirstInstance: + MachineKeyPath: '/var/zitadel_output/sa-key.json' + PatPath: '/var/zitadel_output/pat.txt' + Org: + Human: + PasswordChangeRequired: false + Username: zitadel-admin@zitadel.localhost + Password: Password1! + Machine: + Machine: + Username: api-user + Name: Combined API User + MachineKey: + ExpirationDate: '2030-01-01T00:00:00Z' + Type: 1 + Pat: + ExpirationDate: '2030-01-01T00:00:00Z' + Applications: + - OIDC: + RedirectUris: + - http://localhost:8080/callback + - http://127.0.0.1:8080/callback + ResponseTypes: + - CODE + - ID_TOKEN + - TOKEN + GrantTypes: + - AUTHORIZATION_CODE + - IMPLICIT + - REFRESH_TOKEN + - CLIENT_CREDENTIALS + AuthMethodType: POST + Name: 'MyOAuthAPIClient' + Type: 'WEB' +DefaultInstance.LoginPolicy.MfaInitSkipLifetime: "0s" diff --git a/etc/example-zitadel-secrets.yaml b/etc/example-zitadel-secrets.yaml new file mode 100644 index 00000000..66d495cb --- /dev/null +++ b/etc/example-zitadel-secrets.yaml @@ -0,0 +1,8 @@ +Database: + postgres: + User: + Username: 'zitadel_user' + Password: 'zitadel' + Admin: + Username: 'root' + Password: 'postgres' diff --git a/spec/auth/use_access_token_spec.rb b/spec/auth/use_access_token_spec.rb index e8e621ba..3d4bd38d 100644 --- a/spec/auth/use_access_token_spec.rb +++ b/spec/auth/use_access_token_spec.rb @@ -2,6 +2,7 @@ require 'minitest/autorun' require_relative '../spec_helper' +require_relative '../base_spec' # SettingsService Integration Tests (Personal Access Token) # @@ -13,24 +14,15 @@ # # Each test runs in isolation: the client is instantiated in each example to # guarantee a clean, stateless call. -describe 'Zitadel SettingsService (Personal Access Token)' do - let(:base_url) { ENV.fetch('BASE_URL') { raise 'BASE_URL not set' } } - let(:valid_token) { ENV.fetch('AUTH_TOKEN') { raise 'AUTH_TOKEN not set' } } - let(:zitadel_client) do - Zitadel::Client::Zitadel.with_access_token( - base_url, - valid_token - ) - end - +class UseAccessTokenSpec < BaseSpec it 'retrieves general settings with valid token' do - client = zitadel_client + client = Zitadel::Client::Zitadel.with_access_token(@base_url, @auth_token) client.settings.settings_service_get_general_settings end it 'raises an ApiError with invalid token' do client = Zitadel::Client::Zitadel.with_access_token( - base_url, + @base_url, 'invalid' ) assert_raises(Zitadel::Client::ZitadelError) do diff --git a/spec/auth/use_client_credentials_spec.rb b/spec/auth/use_client_credentials_spec.rb index 22d3e8b3..c4cc65ab 100644 --- a/spec/auth/use_client_credentials_spec.rb +++ b/spec/auth/use_client_credentials_spec.rb @@ -2,6 +2,10 @@ require 'minitest/autorun' require_relative '../spec_helper' +require_relative '../base_spec' +require 'net/http' +require 'uri' +require 'json' # SettingsService Integration Tests (Client Credentials) # @@ -13,26 +17,72 @@ # # Each test runs in isolation: the client is instantiated in each example to # guarantee a clean, stateless call. -describe 'Zitadel SettingsService (Client Credentials)' do - let(:base_url) { ENV.fetch('BASE_URL') { raise 'BASE_URL not set' } } - let(:client_id) { ENV.fetch('CLIENT_ID') { raise 'CLIENT_ID not set' } } - let(:client_secret) { ENV.fetch('CLIENT_SECRET') { raise 'CLIENT_SECRET not set' } } - let(:zitadel_client) do - Zitadel::Client::Zitadel.with_client_credentials( - base_url, - client_id, - client_secret - ) +class UseClientCredentialsSpec < BaseSpec + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity + def generate_user_secret(token, login_name = 'api-user') + user_id_uri = URI("http://localhost:8099/management/v1/global/users/_by_login_name?loginName=#{URI.encode_www_form_component(login_name)}") + + user_id_http = Net::HTTP.new(user_id_uri.host, user_id_uri.port) + user_id_request = Net::HTTP::Get.new(user_id_uri) + user_id_request['Authorization'] = "Bearer #{token}" + user_id_request['Accept'] = 'application/json' + + user_id_response = user_id_http.request(user_id_request) + + unless user_id_response.is_a?(Net::HTTPSuccess) + raise "API call to retrieve user failed for login name: '#{login_name}'. Response: #{user_id_response.body}" + end + + user_response_map = JSON.parse(user_id_response.body) + user_id = user_response_map.dig('user', 'id') + + if user_id && !user_id.empty? + secret_uri = URI("http://localhost:8099/management/v1/users/#{user_id}/secret") + + secret_http = Net::HTTP.new(secret_uri.host, secret_uri.port) + secret_request = Net::HTTP::Put.new(secret_uri) + secret_request['Authorization'] = "Bearer #{token}" + secret_request['Content-Type'] = 'application/json' + secret_request['Accept'] = 'application/json' + secret_request.body = '{}' + + secret_response = secret_http.request(secret_request) + + unless secret_response.is_a?(Net::HTTPSuccess) + raise "API call to generate secret failed for user ID: '#{user_id}'. Response: #{secret_response.body}" + end + + secret_data = JSON.parse(secret_response.body) + client_id = secret_data['clientId'] + client_secret = secret_data['clientSecret'] + + if client_id && !client_id.empty? && client_secret && !client_secret.empty? + return { clientId: client_id, clientSecret: client_secret } + end + + puts secret_response.body + raise "API response for secret is missing 'clientId' or 'clientSecret'." + + else + puts user_id_response.body + raise "Could not parse a valid user ID from API response for login name: '#{login_name}'." + end end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity it 'retrieves general settings with valid credentials' do - client = zitadel_client + credentials = generate_user_secret(@auth_token, 'api-user') + client = Zitadel::Client::Zitadel.with_client_credentials( + @base_url, + credentials[:clientId], + credentials[:clientSecret] + ) client.settings.settings_service_get_general_settings end it 'raises an ApiError with invalid credentials' do client = Zitadel::Client::Zitadel.with_client_credentials( - base_url, + @base_url, 'invalid', 'invalid' ) diff --git a/spec/auth/use_private_key_spec.rb b/spec/auth/use_private_key_spec.rb index 490ae015..090230d8 100644 --- a/spec/auth/use_private_key_spec.rb +++ b/spec/auth/use_private_key_spec.rb @@ -3,6 +3,7 @@ require 'minitest/autorun' require_relative '../spec_helper' require 'tempfile' +require_relative '../base_spec' # SettingsService Integration Tests (Private Key Assertion) # @@ -14,31 +15,16 @@ # # Each test runs in isolation: the client is instantiated in each example to # guarantee a clean, stateless call. -describe 'Zitadel SettingsService (Private Key Assertion)' do - let(:base_url) { ENV.fetch('BASE_URL') { raise 'BASE_URL not set' } } - let(:jwt_file) do - file = Tempfile.new(%w[jwt .json]) - file.write(ENV.fetch('JWT_KEY') { raise 'JWT_KEY not set' }) - file.flush - file.close - file - end - let(:zitadel_client) do - Zitadel::Client::Zitadel.with_private_key( - base_url, - jwt_file.path - ) - end - +class UsePrivateKeySpec < BaseSpec it 'retrieves general settings with valid private key' do - client = zitadel_client + client = Zitadel::Client::Zitadel.with_private_key(@base_url, @jwt_key) client.settings.settings_service_get_general_settings end it 'raises an ApiError with invalid private key' do client = Zitadel::Client::Zitadel.with_private_key( 'https://zitadel.cloud', - jwt_file.path + @jwt_key ) assert_raises(Zitadel::Client::ZitadelError) do client.settings.settings_service_get_general_settings diff --git a/spec/base_spec.rb b/spec/base_spec.rb new file mode 100644 index 00000000..6d0f663f --- /dev/null +++ b/spec/base_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require 'logger' +require 'tempfile' +require 'minitest/hooks/test' + +LOGGER = Logger.new($stdout) +LOGGER.level = Logger::INFO + +# Abstract base class for integration tests that interact with a Docker +# Compose stack. +# +# This class handles the lifecycle of the Docker Compose environment, +# bringing it up before tests run and tearing it down afterward. It also +# provides mechanisms to load specific data (like authentication tokens +# and JWT keys) from environment variables and makes them accessible via +# instance variables for use in concrete test implementations. +class BaseSpec < Minitest::Spec + # noinspection RbsMissingTypeSignature + include Minitest::Hooks + + # Set up the Docker Compose environment + # + # This method brings up the Docker Compose stack before each test run, + # waits for the services to initialize, and loads necessary data such as + # authentication tokens and JWT keys into instance variables. + # rubocop:disable Metrics/MethodLength + def setup_docker_compose + @compose_file_path = File.join(File.dirname(__FILE__), '..', 'etc', 'docker-compose.yaml') + raise 'docker-compose file not found!' unless File.exist?(@compose_file_path) + + command = [ + 'docker', 'compose', '--file', @compose_file_path, + 'up', '--detach', '--no-color', '--quiet-pull', '--yes' + ] + result = `#{command.join(' ')}` + raise "Failed to bring up Docker Compose stack. Error: #{result}" unless $CHILD_STATUS.success? + + LOGGER.info('Docker Compose stack is up.') + sleep 20 + + @base_url = 'http://localhost:8099' + @auth_token = load_file_content_into_property('zitadel_output/pat.txt', 'auth_token') + + jwt_key_path = File.join(File.dirname(__FILE__), '..', 'etc', 'zitadel_output', 'sa-key.json') + raise 'JWT Key file not found!' unless File.exist?(jwt_key_path) + + @jwt_key = jwt_key_path + LOGGER.info("Loaded JWT_KEY path: #{@jwt_key}") + end + # rubocop:enable Metrics/MethodLength + + # Tear down the Docker Compose environment + # + # This method stops the Docker Compose stack and removes any associated + # volumes after the tests are finished. + def teardown_docker_compose + command = [ + 'docker', 'compose', '--file', @compose_file_path, + 'down', '-v' + ] + result = `#{command.join(' ')}` + raise "Failed to tear down Docker Compose stack. Error: #{result}" unless $CHILD_STATUS.success? + + LOGGER.info('Docker Compose stack torn down.') + end + + # Setup before each test + # + # This callback runs before each test in the class. It will start the + # Docker Compose environment and load necessary configuration. + def before_all + setup_docker_compose + end + + # Teardown after each test + # + # This callback runs after each test in the class. It will stop and + # clean up the Docker Compose environment to ensure a clean state for + # the next test. + def after_all + teardown_docker_compose + end + + def load_file_content_into_property(relative_path, property_name) + file_path = File.join(File.dirname(__FILE__), '..', 'etc', relative_path) + + raise "File not found for property '#{property_name}': #{file_path}" unless File.exist?(file_path) + + content = File.read(file_path).strip + LOGGER.info("Loaded #{file_path} content into property: #{property_name}") + content + end +end diff --git a/spec/check_session_service_spec.rb b/spec/check_session_service_spec.rb index 10effab6..7536c25e 100644 --- a/spec/check_session_service_spec.rb +++ b/spec/check_session_service_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'minitest/autorun' +require_relative 'base_spec' # SessionService Integration Tests # @@ -18,24 +19,34 @@ require_relative 'spec_helper' require 'securerandom' -describe 'Zitadel SessionService' do - let(:base_url) { ENV.fetch('BASE_URL') { raise 'BASE_URL not set' } } - let(:valid_token) { ENV.fetch('AUTH_TOKEN') { raise 'AUTH_TOKEN not set' } } - let(:client) do - Zitadel::Client::Zitadel.with_access_token( - base_url, - valid_token - ) +class SessionServiceSanityCheckSpec < BaseSpec + def client + Zitadel::Client::Zitadel.with_access_token(@base_url, @auth_token) end before do - req = Zitadel::Client::Models::SessionServiceCreateSessionRequest.new( + username = SecureRandom.hex + request = Zitadel::Client::Models::UserServiceAddHumanUserRequest.new( + username: username, + profile: Zitadel::Client::Models::UserServiceSetHumanProfile.new( + given_name: 'John', + family_name: 'Doe' + ), + email: Zitadel::Client::Models::UserServiceSetHumanEmail.new( + email: "johndoe#{SecureRandom.hex}@example.com" + ) + ) + + @user = client.users.user_service_add_human_user(request) + request = Zitadel::Client::Models::SessionServiceCreateSessionRequest.new( checks: Zitadel::Client::Models::SessionServiceChecks.new( - user: Zitadel::Client::Models::SessionServiceCheckUser.new(login_name: 'johndoe') + user: Zitadel::Client::Models::SessionServiceCheckUser.new( + login_name: username + ) ), lifetime: '18000s' ) - resp = client.sessions.session_service_create_session(req) + resp = client.sessions.session_service_create_session(request) @session_id = resp.session_id @session_token = resp.session_token end diff --git a/spec/check_user_service_spec.rb b/spec/check_user_service_spec.rb index 6ad4f4dd..dff29491 100644 --- a/spec/check_user_service_spec.rb +++ b/spec/check_user_service_spec.rb @@ -3,6 +3,7 @@ require 'minitest/autorun' require_relative 'spec_helper' require 'securerandom' +require_relative 'base_spec' # UserService Integration Tests # @@ -18,14 +19,9 @@ # Each test runs in isolation: a new user is created in `before` and deleted in # `after` to ensure a clean state. -describe 'Zitadel UserService' do - let(:base_url) { ENV.fetch('BASE_URL') { raise 'BASE_URL not set' } } - let(:valid_token) { ENV.fetch('AUTH_TOKEN') { raise 'AUTH_TOKEN not set' } } - let(:client) do - Zitadel::Client::Zitadel.with_access_token( - base_url, - valid_token - ) +class UserServiceSanityCheckSpec < BaseSpec + def client + Zitadel::Client::Zitadel.with_access_token(@base_url, @auth_token) end before do