diff --git a/qa/qa/specs/features/ee/browser_ui/10_software_supply_chain_security/secrets_management/project_secret_ci_access_spec.rb b/qa/qa/specs/features/ee/browser_ui/10_software_supply_chain_security/secrets_management/project_secret_ci_access_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1f7be060356826560218d2299fa1e19e2717e2b9 --- /dev/null +++ b/qa/qa/specs/features/ee/browser_ui/10_software_supply_chain_security/secrets_management/project_secret_ci_access_spec.rb @@ -0,0 +1,311 @@ +# frozen_string_literal: true + +module QA + RSpec.describe( + 'Software Supply Chain Security', + :secrets_manager, + only: { job: 'gdk-instance-secrets-manager' }, + feature_category: :secrets_management + ) do + include_context 'secrets manager base' + + describe 'Project Secret CI Access' do + let(:secret_name) { 'Test' } + let(:secret_value) { 'my-secret-value-for-ci' } + + let(:ci_file_content) do + <<~YAML + job: + secrets: + TEST_SECRET: + gitlab_secrets_manager: + name: #{secret_name} + script: + - echo "testing OpenBao in CI" + - cat $TEST_SECRET | wc | grep '21$' + - echo "This is my secret: $(cat $TEST_SECRET)" + YAML + end + + before do + # Debug: Check runner availability FIRST + check_runners_before_test + + Page::Main::Menu.perform(&:sign_out) + Flow::Login.sign_in(as: owner) + project.visit! + + Page::Project::Menu.perform(&:go_to_secrets_manager) + EE::Page::Project::Secure::SecretsManager.perform do |secrets_page| + secrets_page.click_new_secret + secrets_page.create_secret( + name: secret_name, + value: secret_value, + description: "Secret for CI pipeline test", + environment: '*', + branch: 'main' + ) + end + end + + def check_runners_before_test + log_runner_check_header + admin_client = Runtime::API::Client.as_admin + + instance_runners = fetch_instance_runners(admin_client) + project_runners = fetch_project_runners(admin_client) + + log_runners_info(instance_runners, project_runners) + validate_runners_available(instance_runners, project_runners) + end + + def log_runner_check_header + QA::Runtime::Logger.info("=" * 80) + QA::Runtime::Logger.info("CHECKING RUNNER AVAILABILITY") + QA::Runtime::Logger.info("=" * 80) + end + + def fetch_instance_runners(admin_client) + response = admin_client.get("/runners/all") + parse_body(response) + end + + def fetch_project_runners(admin_client) + response = admin_client.get("/projects/#{project.id}/runners") + parse_body(response) + end + + def log_runners_info(instance_runners, project_runners) + log_runner_list("Instance runners", instance_runners) + log_runner_list("Project runners", project_runners) + log_runner_summary(instance_runners, project_runners) + end + + def log_runner_list(label, runners) + QA::Runtime::Logger.info("#{label}: #{runners.count}") + runners.each do |runner| + QA::Runtime::Logger.info(" - #{runner[:description]} (ID: #{runner[:id]})") + log_runner_status(runner) + QA::Runtime::Logger.info(" Tags: #{runner[:tag_list].inspect}") + QA::Runtime::Logger.info(" Run untagged: #{runner[:run_untagged]}") + end + end + + def log_runner_status(runner) + status = runner[:status] + active = runner[:active] + online = runner[:online] + QA::Runtime::Logger.info(" Status: #{status}, Active: #{active}, Online: #{online}") + end + + def log_runner_summary(instance_runners, project_runners) + total = instance_runners.count + project_runners.count + online = (instance_runners + project_runners).count { |r| r[:online] && r[:active] } + + QA::Runtime::Logger.info("=" * 80) + QA::Runtime::Logger.info("SUMMARY: #{total} total runners, #{online} online and active") + QA::Runtime::Logger.info("=" * 80) + end + + def validate_runners_available(instance_runners, project_runners) + online_runners = (instance_runners + project_runners).count { |r| r[:online] && r[:active] } + + return unless online_runners == 0 + + raise "NO RUNNERS AVAILABLE! Cannot execute CI pipeline tests. " \ + "The GDK instance needs at least one registered, active, and online runner." + end + + def add_ci_file(content, commit_message, expected_status: 'success') + create_ci_file_commit(content, commit_message) + Flow::Pipeline.wait_for_pipeline_creation_via_api(project: project) + + admin_client = Runtime::API::Client.as_admin + pipeline = fetch_latest_pipeline(admin_client) + + log_initial_pipeline_status(pipeline) + check_pipeline_stuck_status(admin_client, pipeline) + + wait_for_pipeline_completion(expected_status, admin_client, pipeline[:id]) + end + + def create_ci_file_commit(content, commit_message) + file_exists = project.has_file?('.gitlab-ci.yml') + + Resource::Repository::Commit.fabricate_via_api! do |commit| + commit.api_client = Runtime::User::Store.admin_api_client + commit.project = project + commit.commit_message = commit_message + + if file_exists + commit.update_files([{ file_path: '.gitlab-ci.yml', content: content }]) + else + commit.add_files([{ file_path: '.gitlab-ci.yml', content: content }]) + end + end + end + + def fetch_latest_pipeline(admin_client) + response = admin_client.get("/projects/#{project.id}/pipelines") + pipelines = parse_body(response) + pipelines.first + end + + def log_initial_pipeline_status(pipeline) + QA::Runtime::Logger.info("Pipeline created: ID=#{pipeline[:id]}, Status=#{pipeline[:status]}") + sleep 5 + + admin_client = Runtime::API::Client.as_admin + response = admin_client.get("/projects/#{project.id}/pipelines/#{pipeline[:id]}") + updated_pipeline = parse_body(response) + QA::Runtime::Logger.info("Status after 5s: #{updated_pipeline[:status]}") + end + + def check_pipeline_stuck_status(admin_client, pipeline) + response = admin_client.get("/projects/#{project.id}/pipelines/#{pipeline[:id]}") + current_pipeline = parse_body(response) + + return unless current_pipeline[:status] == 'created' || current_pipeline[:status] == 'pending' + + QA::Runtime::Logger.error("Pipeline is stuck in '#{current_pipeline[:status]}' status!") + QA::Runtime::Logger.error("This means no runner is picking up the job.") + + log_stuck_pipeline_jobs(admin_client, pipeline[:id]) + QA::Runtime::Logger.error("Re-checking runners...") + check_runners_before_test + end + + def log_stuck_pipeline_jobs(admin_client, pipeline_id) + response = admin_client.get("/projects/#{project.id}/pipelines/#{pipeline_id}/jobs") + jobs = parse_body(response) + + jobs.each do |job| + QA::Runtime::Logger.error("Job '#{job[:name]}':") + QA::Runtime::Logger.error(" Status: #{job[:status]}") + QA::Runtime::Logger.error(" Tags: #{job[:tag_list].inspect}") + QA::Runtime::Logger.error(" Runner: #{job[:runner].inspect}") + end + end + + def wait_for_pipeline_completion(expected_status, _admin_client, pipeline_id) + Flow::Pipeline.wait_for_latest_pipeline_to_have_status( + project: project, + status: expected_status + ) + rescue StandardError => e + log_pipeline_diagnostics(pipeline_id) + raise e + end + + def log_pipeline_diagnostics(pipeline_id) + admin_client = Runtime::API::Client.as_admin + + QA::Runtime::Logger.error("=" * 80) + QA::Runtime::Logger.error("PIPELINE TIMEOUT DIAGNOSTICS") + QA::Runtime::Logger.error("=" * 80) + + pipeline_response = admin_client.get("/projects/#{project.id}/pipelines/#{pipeline_id}") + pipeline = parse_body(pipeline_response) + QA::Runtime::Logger.error("Final pipeline status: #{pipeline[:status]}") + QA::Runtime::Logger.error("Pipeline web_url: #{pipeline[:web_url]}") + + jobs_response = admin_client.get("/projects/#{project.id}/pipelines/#{pipeline_id}/jobs") + jobs = parse_body(jobs_response) + + jobs.each do |job| + QA::Runtime::Logger.error("\nJob '#{job[:name]}':") + QA::Runtime::Logger.error(" ID: #{job[:id]}") + QA::Runtime::Logger.error(" Status: #{job[:status]}") + QA::Runtime::Logger.error(" Tags: #{job[:tag_list].inspect}") + QA::Runtime::Logger.error(" Created: #{job[:created_at]}") + QA::Runtime::Logger.error(" Started: #{job[:started_at]}") + QA::Runtime::Logger.error(" Runner: #{job[:runner].inspect}") + QA::Runtime::Logger.error(" Failure reason: #{job[:failure_reason]}") + + # Try to get trace + begin + trace_response = admin_client.get("/projects/#{project.id}/jobs/#{job[:id]}/trace") + trace = trace_response.body + if trace && !trace.empty? + QA::Runtime::Logger.error(" Trace:\n#{trace}") + else + QA::Runtime::Logger.error(" Trace: (empty or not available)") + end + rescue StandardError => trace_error + QA::Runtime::Logger.error(" Could not fetch trace: #{trace_error.message}") + end + end + end + + context 'when accessing secrets in CI pipeline' do + it 'successfully accesses secret in CI pipeline job', + testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/583388' do + add_ci_file(ci_file_content, 'Add CI config to test secrets access', expected_status: 'success') + + project.visit_latest_pipeline + + Page::Project::Pipeline::Show.perform do |pipeline_page| + expect(pipeline_page).to have_job('job') + pipeline_page.click_job('job') + end + + Page::Project::Job::Show.perform do |job_page| + expect(job_page.successful?(timeout: 120)).to be_truthy, "Job did not complete successfully" + job_log = job_page.output(wait: 10) + + aggregate_failures 'Job output verification' do + expect(job_log).to include('testing OpenBao in CI') + expect(job_log).to include('done.') + expect(job_log).to include('Job succeeded') + expect(job_log).to match(/\s+\d+\s+\d+\s+#{secret_value.length}/) + expect(job_log).not_to include(secret_value), "Secret value should be masked in logs" + expect(job_log).to include('***'), "Secret should be masked with asterisks in logs" + end + end + end + + it 'fails when accessing non-existent secret in CI pipeline job', + testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/583389' do + nonexistent_secret_ci_config = <<~YAML + job: + secrets: + TEST_SECRET: + gitlab_secrets_manager: + name: non_existent_secret + script: + - echo "Attempting to access non-existent secret" + - test -n "$TEST_SECRET" || exit 1 + - cat $TEST_SECRET + - echo "This should not be reached" + YAML + + # Manually create commit and wait for FAILED status + Resource::Repository::Commit.fabricate_via_api! do |commit| + commit.api_client = Runtime::User::Store.admin_api_client + commit.project = project + commit.commit_message = 'Test non-existent secret access' + commit.add_files([{ file_path: '.gitlab-ci.yml', content: nonexistent_secret_ci_config }]) + end + + Flow::Pipeline.wait_for_pipeline_creation_via_api(project: project) + + # Expect FAILED status, not success + Flow::Pipeline.wait_for_latest_pipeline_to_have_status(project: project, status: 'failed') + + project.visit_latest_pipeline + + Page::Project::Pipeline::Show.perform do |pipeline_page| + expect(pipeline_page).to have_job('job') + pipeline_page.click_job('job') + end + + Page::Project::Job::Show.perform do |job_page| + expect(job_page.has_job_log?(wait: 60)).to be_truthy, "Job log did not appear" + job_log = job_page.output(wait: 10) + expect(job_log).to include('Job failed') + end + end + end + end + end +end