# frozen_string_literal: true

require 'spec_helper'

RSpec.describe 'Query.runner(id)', :freeze_time, feature_category: :fleet_visibility do
  include GraphqlHelpers

  using RSpec::Parameterized::TableSyntax

  let_it_be(:user) { create(:user, :admin) }
  let_it_be(:another_admin) { create(:user, :admin) }
  let_it_be_with_reload(:group) { create(:group) }

  let_it_be(:active_instance_runner) do
    create(:ci_runner, :instance, :with_runner_manager, :tagged_only, :offline,
      description: 'Runner 1',
      creator: user,
      active: true,
      locked: true,
      maximum_timeout: 600,
      access_level: 0,
      maintenance_note: '**Test maintenance note**')
  end

  let_it_be(:paused_instance_runner) do
    create(:ci_runner, :instance, :offline, :paused, :tagged_only,
      description: 'Runner 2',
      creator: another_admin,
      access_level: 1)
  end

  let_it_be(:active_group_runner) do
    create(:ci_runner, :group, :offline, :tagged_only, :locked,
      groups: [group],
      description: 'Group runner 1',
      maximum_timeout: 600,
      access_level: 0)
  end

  let_it_be(:project1) { create(:project) }
  let_it_be(:active_project_runner) { create(:ci_runner, :project, :with_runner_manager, projects: [project1]) }

  shared_examples 'runner details fetch' do
    let(:query) do
      wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner')))
    end

    let(:query_path) do
      [
        [:runner, { id: runner.to_global_id.to_s }]
      ]
    end

    it 'retrieves expected fields' do
      stub_commonmark_sourcepos_disabled

      post_graphql(query, current_user: user)

      runner_data = graphql_data_at(:runner)
      expect(runner_data).not_to be_nil

      expect(runner_data).to match a_graphql_entity_for(
        runner,
        description: runner.description,
        created_by: runner.creator ? a_graphql_entity_for(runner.creator) : nil,
        created_at: runner.created_at&.iso8601,
        contacted_at: runner.contacted_at&.iso8601,
        short_sha: runner.short_sha,
        locked: false,
        active: runner.active,
        paused: !runner.active,
        status: runner.status.to_s.upcase,
        job_execution_status: runner.builds.executing.any? ? 'ACTIVE' : 'IDLE',
        maximum_timeout: runner.maximum_timeout,
        access_level: runner.access_level.to_s.upcase,
        run_untagged: runner.run_untagged,
        runner_type: runner.instance_type? ? 'INSTANCE_TYPE' : 'PROJECT_TYPE',
        creation_method: runner.authenticated_user_registration_type? ? 'AUTHENTICATED_USER' : 'REGISTRATION_TOKEN',
        ephemeral_authentication_token: nil,
        maintenance_note: runner.maintenance_note,
        maintenance_note_html:
          runner.maintainer_note.present? ? a_string_including('<strong>Test maintenance note</strong>') : '',
        job_count: runner.builds.count,
        jobs: a_hash_including(
          'count' => runner.builds.count,
          'nodes' => an_instance_of(Array),
          'pageInfo' => anything
        ),
        project_count: nil,
        admin_url: "http://localhost/admin/runners/#{runner.id}",
        edit_admin_url: "http://localhost/admin/runners/#{runner.id}/edit",
        register_admin_url: runner.registration_available? ? "http://localhost/admin/runners/#{runner.id}/register" : nil,
        user_permissions: {
          'readRunner' => true,
          'updateRunner' => true,
          'deleteRunner' => true,
          'assignRunner' => true
        },
        managers: a_hash_including(
          'count' => runner.runner_managers.count,
          'nodes' => runner.runner_managers.map do |runner_manager|
            a_graphql_entity_for(
              runner_manager,
              system_id: runner_manager.system_xid,
              version: runner_manager.version,
              revision: runner_manager.revision,
              ip_address: runner_manager.ip_address,
              executor_name: runner_manager.executor_type&.dasherize,
              architecture_name: runner_manager.architecture,
              platform_name: runner_manager.platform,
              status: runner_manager.status.to_s.upcase,
              job_execution_status: runner_manager.builds.executing.any? ? 'ACTIVE' : 'IDLE'
            )
          end,
          "pageInfo" => anything
        )
      )
      expect(runner_data['tagList']).to match_array runner.tag_list
    end
  end

  shared_examples 'retrieval with no admin url' do
    let(:query) do
      wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner')))
    end

    let(:query_path) do
      [
        [:runner, { id: runner.to_global_id.to_s }]
      ]
    end

    it 'retrieves expected fields' do
      post_graphql(query, current_user: user)

      runner_data = graphql_data_at(:runner)
      expect(runner_data).not_to be_nil

      expect(runner_data).to match a_graphql_entity_for(runner, admin_url: nil, edit_admin_url: nil)
      expect(runner_data['tagList']).to match_array runner.tag_list
    end
  end

  shared_examples 'retrieval by unauthorized user' do
    let(:query) do
      wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner')))
    end

    let(:query_path) do
      [
        [:runner, { id: runner.to_global_id.to_s }]
      ]
    end

    it 'returns null runner' do
      post_graphql(query, current_user: user)

      expect(graphql_data_at(:runner)).to be_nil
    end
  end

  describe 'for active runner' do
    let(:runner) { active_instance_runner }

    it_behaves_like 'runner details fetch'

    context 'when tagList is not requested' do
      let(:query) do
        wrap_fields(query_graphql_path(query_path, 'id'))
      end

      let(:query_path) do
        [
          [:runner, { id: runner.to_global_id.to_s }]
        ]
      end

      it 'does not retrieve tagList' do
        post_graphql(query, current_user: user)

        runner_data = graphql_data_at(:runner)
        expect(runner_data).not_to be_nil
        expect(runner_data).not_to include('tagList')
      end
    end

    context 'with runner managers' do
      let_it_be(:runner) { create(:ci_runner) }
      let_it_be(:runner_manager) do
        create(:ci_runner_machine, :online,
          runner: runner, ip_address: '127.0.0.1', version: '16.3', revision: 'a', architecture: 'arm', platform: 'osx',
          executor_type: 'docker')
      end

      describe 'managers' do
        let_it_be(:runner2) { create(:ci_runner) }
        let_it_be(:runner_manager2_1) { create(:ci_runner_machine, runner: runner2) }
        let_it_be(:runner_manager2_2) { create(:ci_runner_machine, runner: runner2) }

        context 'when filtering by status' do
          let!(:offline_runner_manager) { create(:ci_runner_machine, :offline, runner: runner2) }

          let(:query) do
            %(
              query {
                runner(id: "#{runner2.to_global_id}") {
                  id
                  managers(status: OFFLINE) { nodes { id } }
                }
              }
            )
          end

          it 'retrieves expected runner manager' do
            post_graphql(query, current_user: user)

            expect(graphql_data).to match(a_hash_including(
              'runner' => a_graphql_entity_for(
                'managers' => {
                  'nodes' => [a_graphql_entity_for(offline_runner_manager)]
                }
              )
            ))
          end
        end

        context 'fetching by runner ID and runner system ID' do
          let(:query) do
            %(
              query {
                runner1: runner(id: "#{runner.to_global_id}") {
                  id
                  managers(systemId: "#{runner_manager.system_xid}") { nodes { id } }
                }
                runner2: runner(id: "#{runner2.to_global_id}") {
                  id
                  managers(systemId: "#{runner_manager2_1.system_xid}") { nodes { id } }
                }
              }
            )
          end

          it 'retrieves expected runner managers' do
            post_graphql(query, current_user: user)

            expect(graphql_data).to match(a_hash_including(
              'runner1' => a_graphql_entity_for(runner,
                'managers' => a_hash_including('nodes' => [a_graphql_entity_for(runner_manager)])),
              'runner2' => a_graphql_entity_for(runner2,
                'managers' => a_hash_including('nodes' => [a_graphql_entity_for(runner_manager2_1)]))
            ))
          end
        end

        context 'fetching runner ID and all runner managers' do
          let(:query) do
            %(
              query {
                runner(id: "#{runner2.to_global_id}") { id managers { nodes { id } } }
              }
            )
          end

          it 'retrieves expected runner managers' do
            post_graphql(query, current_user: user)

            expect(graphql_data).to match(a_hash_including(
              'runner' => a_graphql_entity_for(runner2,
                'managers' => a_hash_including('nodes' => [
                  a_graphql_entity_for(runner_manager2_2), a_graphql_entity_for(runner_manager2_1)
                ]))
            ))
          end
        end

        context 'fetching mismatched runner ID and system ID' do
          let(:query) do
            %(
              query {
                runner(id: "#{runner2.to_global_id}") {
                  id
                  managers(systemId: "#{runner_manager.system_xid}") { nodes { id } }
                }
              }
            )
          end

          it 'retrieves expected runner managers' do
            post_graphql(query, current_user: user)

            expect(graphql_data).to match(a_hash_including(
              'runner' => a_graphql_entity_for(runner2, 'managers' => a_hash_including('nodes' => []))
            ))
          end
        end
      end

      context 'with build running' do
        let!(:pipeline) { create(:ci_pipeline, project: project1) }
        let!(:build) { create(:ci_build, :running, runner: runner, pipeline: pipeline) }

        before do
          create(:ci_runner_machine_build, runner_manager: runner_manager, build: build)
        end

        it_behaves_like 'runner details fetch'
      end
    end
  end

  describe 'for project runner' do
    let_it_be_with_refind(:project_runner) do
      create(
        :ci_runner, :project, :paused, :offline,
        projects: [project1],
        description: 'Runner 3',
        locked: false,
        access_level: 1,
        run_untagged: true
      )
    end

    describe 'locked' do
      where(is_locked: [true, false])

      with_them do
        before do
          project_runner.update!(locked: is_locked)
        end

        let(:query) do
          wrap_fields(query_graphql_path(query_path, 'id locked'))
        end

        let(:query_path) do
          [
            [:runner, { id: project_runner.to_global_id.to_s }]
          ]
        end

        it 'retrieves correct locked value' do
          post_graphql(query, current_user: user)

          runner_data = graphql_data_at(:runner)

          expect(runner_data).to match a_graphql_entity_for(project_runner, locked: is_locked)
        end
      end
    end

    describe 'jobCount' do
      let_it_be(:pipeline1) { create(:ci_pipeline, project: project1) }
      let_it_be(:pipeline2) { create(:ci_pipeline, project: project1) }
      let_it_be(:build1) { create(:ci_build, :running, runner: active_project_runner, pipeline: pipeline1) }
      let_it_be(:build2) { create(:ci_build, :running, runner: active_project_runner, pipeline: pipeline2) }

      let(:query) do
        %(
          query {
            runner1: runner(id: "#{active_project_runner.to_global_id}") { id jobCount(statuses: [RUNNING]) }
            runner2: runner(id: "#{active_project_runner.to_global_id}") { id jobCount(statuses: FAILED) }
            runner3: runner(id: "#{active_project_runner.to_global_id}") { id jobCount }
            runner4: runner(id: "#{paused_instance_runner.to_global_id}") { id jobCount }
          }
        )
      end

      it 'retrieves correct jobCount values' do
        post_graphql(query, current_user: user)

        expect(graphql_data).to match a_hash_including(
          'runner1' => a_graphql_entity_for(active_project_runner, job_count: 2),
          'runner2' => a_graphql_entity_for(active_project_runner, job_count: 0),
          'runner3' => a_graphql_entity_for(active_project_runner, job_count: 2),
          'runner4' => a_graphql_entity_for(paused_instance_runner, job_count: 0)
        )
      end

      context 'when JOB_COUNT_LIMIT is in effect' do
        before do
          stub_const('Types::Ci::RunnerType::JOB_COUNT_LIMIT', 0)
        end

        it 'retrieves correct capped jobCount values' do
          post_graphql(query, current_user: user)

          expect(graphql_data).to match a_hash_including(
            'runner1' => a_graphql_entity_for(active_project_runner, job_count: 1),
            'runner2' => a_graphql_entity_for(active_project_runner, job_count: 0),
            'runner3' => a_graphql_entity_for(active_project_runner, job_count: 1),
            'runner4' => a_graphql_entity_for(paused_instance_runner, job_count: 0)
          )
        end
      end
    end

    describe 'ownerProject' do
      let_it_be(:project2) { create(:project) }
      let_it_be(:runner1) { create(:ci_runner, :project, projects: [project2, project1]) }
      let_it_be(:runner2) { create(:ci_runner, :project, projects: [project1, project2]) }

      let(:runner_query_fragment) { 'id ownerProject { id }' }
      let(:query) do
        %(
          query {
            runner1: runner(id: "#{runner1.to_global_id}") { #{runner_query_fragment} }
            runner2: runner(id: "#{runner2.to_global_id}") { #{runner_query_fragment} }
          }
        )
      end

      it 'retrieves correct ownerProject.id values' do
        post_graphql(query, current_user: user)

        expect(graphql_data).to match a_hash_including(
          'runner1' => a_graphql_entity_for(runner1, owner_project: a_graphql_entity_for(project2)),
          'runner2' => a_graphql_entity_for(runner2, owner_project: a_graphql_entity_for(project1))
        )
      end
    end

    describe 'jobs' do
      let(:query) do
        %(
          query {
            runner(id: "#{project_runner.to_global_id}") { #{runner_query_fragment} }
          }
        )
      end

      context 'with a job from a non-owned project' do
        let(:runner_query_fragment) do
          %(
            id
            jobs {
              nodes {
                id status shortSha finishedAt duration queuedDuration tags webPath
                project { id }
                runner { id }
              }
            }
          )
        end

        let_it_be(:owned_project_owner) { create(:user) }
        let_it_be(:owned_project) { create(:project) }
        let_it_be(:other_project) { create(:project) }
        let_it_be(:project_runner) { create(:ci_runner, :project_type, projects: [other_project, owned_project]) }
        let_it_be(:owned_project_pipeline) { create(:ci_pipeline, project: owned_project) }
        let_it_be(:other_project_pipeline) { create(:ci_pipeline, project: other_project) }
        let_it_be(:owned_build) do
          create(:ci_build, :running, runner: project_runner, pipeline: owned_project_pipeline,
            tag_list: %i[a b c], created_at: 1.hour.ago, started_at: 59.minutes.ago, finished_at: 30.minutes.ago)
        end

        let_it_be(:other_build) do
          create(:ci_build, :success, runner: project_runner, pipeline: other_project_pipeline,
            tag_list: %i[d e f], created_at: 30.minutes.ago, started_at: 19.minutes.ago, finished_at: 1.minute.ago)
        end

        before_all do
          owned_project.add_owner(owned_project_owner)
        end

        it 'returns empty values for sensitive fields in non-owned jobs' do
          post_graphql(query, current_user: owned_project_owner)

          jobs_data = graphql_data_at(:runner, :jobs, :nodes)
          expect(jobs_data).not_to be_nil
          expect(jobs_data).to match([
            a_graphql_entity_for(other_build,
              status: other_build.status.upcase,
              project: nil, tags: nil, web_path: nil,
              runner: a_graphql_entity_for(project_runner),
              short_sha: 'Unauthorized', finished_at: other_build.finished_at&.iso8601,
              duration: a_value_within(0.001).of(other_build.duration),
              queued_duration: a_value_within(0.001).of((other_build.started_at - other_build.queued_at).to_f)),
            a_graphql_entity_for(owned_build,
              status: owned_build.status.upcase,
              project: a_graphql_entity_for(owned_project),
              tags: owned_build.tag_list.map(&:to_s),
              web_path: ::Gitlab::Routing.url_helpers.project_job_path(owned_project, owned_build),
              runner: a_graphql_entity_for(project_runner),
              short_sha: owned_build.short_sha,
              finished_at: owned_build.finished_at&.iso8601,
              duration: a_value_within(0.001).of(owned_build.duration),
              queued_duration: a_value_within(0.001).of((owned_build.started_at - owned_build.queued_at).to_f))
          ])
        end
      end
    end

    describe 'a query fetching all fields' do
      let(:query) do
        wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner')))
      end

      let(:query_path) do
        [
          [:runner, { id: project_runner.to_global_id.to_s }]
        ]
      end

      it 'does not execute more queries per runner', :use_sql_query_cache, :aggregate_failures do
        create(:ci_build, :failed, runner: project_runner)
        create(:ci_runner_machine, runner: project_runner, version: '16.4.0')

        # warm-up license cache and so on:
        personal_access_token = create(:personal_access_token, user: user)
        args = { current_user: user, token: { personal_access_token: personal_access_token } }
        post_graphql(query, **args)
        expect(graphql_data_at(:runner)).not_to be_nil

        personal_access_token = create(:personal_access_token, user: another_admin)
        args = { current_user: another_admin, token: { personal_access_token: personal_access_token } }
        control = ActiveRecord::QueryRecorder.new(skip_cached: false) { post_graphql(query, **args) }

        create(:ci_build, :failed, runner: project_runner)
        create(:ci_runner_machine, runner: project_runner, version: '16.4.1')

        expect { post_graphql(query, **args) }.not_to exceed_all_query_limit(control)
      end
    end
  end

  describe 'for paused runner' do
    let(:runner) { paused_instance_runner }

    it_behaves_like 'runner details fetch'
  end

  describe 'for creation method' do
    context 'when created with registration token' do
      let(:runner) do
        create(:ci_runner, registration_type: :registration_token)
      end

      it_behaves_like 'runner details fetch'
    end

    context 'when created by authenticated user' do
      let(:runner) do
        create(:ci_runner, registration_type: :authenticated_user)
      end

      it_behaves_like 'runner details fetch'
    end
  end

  describe 'for group runner request' do
    let(:query) do
      group_path = query_graphql_path(%i[groups nodes], all_graphql_fields_for('GroupInterface'))

      wrap_fields(query_graphql_path(query_path, group_path))
    end

    let(:query_path) do
      [
        [:runner, { id: active_group_runner.to_global_id }]
      ]
    end

    it 'retrieves groups field with expected value' do
      post_graphql(query, current_user: user)

      runner_data = graphql_data_at(:runner, :groups, :nodes)
      expect(runner_data).to contain_exactly(a_graphql_entity_for(group))
    end
  end

  describe 'ephemeralRegisterUrl' do
    let(:runner_args) { { registration_type: :authenticated_user, creator: creator } }
    let(:runner_traits) { [] }
    let(:query) do
      %(
        query {
          runner(id: "#{runner.to_global_id}") {
            ephemeralRegisterUrl
          }
        }
      )
    end

    shared_examples 'has register url' do
      it 'retrieves register url' do
        post_graphql(query, current_user: user)
        expect(graphql_data_at(:runner, :ephemeral_register_url)).to eq(expected_url)
      end
    end

    shared_examples 'has no register url' do
      it 'retrieves no register url' do
        post_graphql(query, current_user: user)
        expect(graphql_data_at(:runner, :ephemeral_register_url)).to eq(nil)
      end
    end

    context 'with an instance runner' do
      let(:creator) { user }
      let(:runner) { create(:ci_runner, *runner_traits, **runner_args) }

      context 'with valid ephemeral registration' do
        let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }

        it_behaves_like 'has register url' do
          let(:expected_url) { "http://localhost/admin/runners/#{runner.id}/register" }
        end
      end

      context 'when runner ephemeral registration has expired' do
        let(:runner_traits) { [:unregistered, :created_after_registration_deadline] }

        it_behaves_like 'has no register url'
      end

      context 'when runner has already been registered' do
        let(:runner_traits) { [:created_before_registration_deadline] }

        it_behaves_like 'has no register url'
      end
    end

    context 'with a group runner' do
      let(:creator) { user }
      let(:runner) { create(:ci_runner, *runner_traits, :group, groups: [group], **runner_args) }

      context 'with valid ephemeral registration' do
        let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }

        it_behaves_like 'has register url' do
          let(:expected_url) { "http://localhost/groups/#{group.path}/-/runners/#{runner.id}/register" }
        end

        context 'when request not from creator' do
          let(:creator) { another_admin }

          before do
            group.add_owner(another_admin)
          end

          it_behaves_like 'has no register url'
        end
      end
    end

    context 'with a project runner' do
      let(:creator) { user }
      let(:runner) { create(:ci_runner, *runner_traits, :project, projects: [project1], **runner_args) }

      context 'with valid ephemeral registration' do
        let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }

        it_behaves_like 'has register url' do
          let(:expected_url) { "http://localhost/#{project1.full_path}/-/runners/#{runner.id}/register" }
        end

        context 'when request not from creator' do
          let(:creator) { another_admin }

          before do
            project1.add_owner(another_admin)
          end

          it_behaves_like 'has no register url'
        end
      end
    end
  end

  describe 'for runner with status' do
    before_all do
      freeze_time # Freeze time before `let_it_be` runs, so that runner statuses are frozen during execution
    end

    after :all do
      unfreeze_time
    end

    let_it_be(:stale_runner) { create(:ci_runner, :stale) }
    let_it_be(:never_contacted_instance_runner) { create(:ci_runner, :unregistered, :created_within_stale_deadline) }

    let(:query) do
      %(
        query {
          staleRunner: runner(id: "#{stale_runner.to_global_id}") { status }
          pausedRunner: runner(id: "#{paused_instance_runner.to_global_id}") { status }
          neverContactedInstanceRunner: runner(id: "#{never_contacted_instance_runner.to_global_id}") { status }
        }
      )
    end

    it 'retrieves status fields with expected values', :aggregate_failures do
      post_graphql(query, current_user: user)

      stale_runner_data = graphql_data_at(:stale_runner)
      expect(stale_runner_data).to eq({ 'status' => 'STALE' })

      paused_runner_data = graphql_data_at(:paused_runner)
      expect(paused_runner_data).to eq({ 'status' => 'OFFLINE' })

      never_contacted_instance_runner_data = graphql_data_at(:never_contacted_instance_runner)
      expect(never_contacted_instance_runner_data).to eq({ 'status' => 'NEVER_CONTACTED' })
    end
  end

  describe 'for multiple runners' do
    let_it_be(:project2) { create(:project, :test_repo) }
    let_it_be(:project_runner1) { create(:ci_runner, :project, projects: [project1, project2], description: 'Runner 1') }
    let_it_be(:project_runner2) { create(:ci_runner, :project, :without_projects, description: 'Runner 2') }

    let!(:job) { create(:ci_build, runner: project_runner1) }

    context 'requesting projects and counts for projects and jobs' do
      let(:jobs_fragment) do
        %(
          jobs {
            count
            nodes {
              id
              status
            }
          }
        )
      end

      let(:query) do
        %(
          query {
            projectRunner1: runner(id: "#{project_runner1.to_global_id}") {
              projectCount
              jobCount
              #{jobs_fragment}
              projects {
                nodes {
                  id
                }
              }
            }
            projectRunner2: runner(id: "#{project_runner2.to_global_id}") {
              projectCount
              jobCount
              #{jobs_fragment}
              projects {
                nodes {
                  id
                }
              }
            }
            activeInstanceRunner: runner(id: "#{active_instance_runner.to_global_id}") {
              projectCount
              jobCount
              #{jobs_fragment}
              projects {
                nodes {
                  id
                }
              }
            }
          }
        )
      end

      before do
        project_runner2.runner_projects.clear

        post_graphql(query, current_user: user)
      end

      it 'retrieves expected fields' do
        runner1_data = graphql_data_at(:project_runner1)
        runner2_data = graphql_data_at(:project_runner2)
        runner3_data = graphql_data_at(:active_instance_runner)

        expect(runner1_data).to match a_hash_including(
          'jobCount' => 1,
          'jobs' => a_hash_including(
            "count" => 1,
            "nodes" => [a_graphql_entity_for(job, status: job.status.upcase)]
          ),
          'projectCount' => 2,
          'projects' => {
            'nodes' => [
              a_graphql_entity_for(project2),
              a_graphql_entity_for(project1)
            ]
          })
        expect(runner2_data).to match a_hash_including(
          'jobCount' => 0,
          'jobs' => nil, # returning jobs not allowed for more than 1 runner (see RunnerJobsResolver)
          'projectCount' => 0,
          'projects' => {
            'nodes' => []
          })
        expect(runner3_data).to match a_hash_including(
          'jobCount' => 0,
          'jobs' => nil, # returning jobs not allowed for more than 1 runner (see RunnerJobsResolver)
          'projectCount' => nil,
          'projects' => nil)

        expect_graphql_errors_to_include [/"jobs" field can be requested only for 1 CiRunner\(s\) at a time./]
      end
    end
  end

  describe 'by regular user' do
    let(:user) { create(:user) }

    context 'on instance runner' do
      let(:runner) { active_instance_runner }

      it_behaves_like 'retrieval by unauthorized user'
    end

    context 'on group runner' do
      let(:runner) { active_group_runner }

      it_behaves_like 'retrieval by unauthorized user'
    end

    context 'on project runner' do
      let(:runner) { active_project_runner }

      it_behaves_like 'retrieval by unauthorized user'
    end
  end

  describe 'by non-admin user' do
    let(:user) { create(:user) }

    before do
      group.add_member(user, Gitlab::Access::OWNER)
    end

    it_behaves_like 'retrieval with no admin url' do
      let(:runner) { active_group_runner }
    end
  end

  describe 'by unauthenticated user' do
    let(:user) { nil }

    it_behaves_like 'retrieval by unauthorized user' do
      let(:runner) { active_instance_runner }
    end
  end

  describe 'ephemeralAuthenticationToken' do
    subject(:request) { post_graphql(query, current_user: user) }

    let_it_be(:creator) { create(:user, owner_of: group) }

    let(:created_at) { Time.current }
    let(:token_prefix) { registration_type == :authenticated_user ? 'glrt-' : '' }
    let(:runner_traits) { [] }
    let(:registration_type) {}
    let(:query) do
      %(
        query {
          runner(id: "#{runner.to_global_id}") {
            id
            ephemeralAuthenticationToken
          }
        }
      )
    end

    let(:runner) do
      create(
        :ci_runner,
        :group,
        *runner_traits,
        groups: [group],
        creator: creator,
        registration_type: registration_type,
        token: "#{token_prefix}abc123"
      )
    end

    shared_examples 'an ephemeral_authentication_token' do
      it 'returns token in ephemeral_authentication_token field' do
        request

        runner_data = graphql_data_at(:runner)
        expect(runner_data).not_to be_nil
        expect(runner_data).to match a_graphql_entity_for(runner, ephemeral_authentication_token: runner.token)
      end
    end

    shared_examples 'a protected ephemeral_authentication_token' do
      it 'returns nil ephemeral_authentication_token' do
        request

        runner_data = graphql_data_at(:runner)
        expect(runner_data).not_to be_nil
        expect(runner_data).to match a_graphql_entity_for(runner, ephemeral_authentication_token: nil)
      end
    end

    context 'with request made by creator', :frozen_time do
      let(:user) { creator }

      context 'with runner created in UI' do
        let(:registration_type) { :authenticated_user }

        context 'with runner created within registration deadline' do
          let(:runner_traits) { [:created_before_registration_deadline] + extra_runner_traits }

          context 'with runner creation not finished' do
            let(:runner_traits) { [:unregistered] }

            it_behaves_like 'an ephemeral_authentication_token'
          end

          context 'with runner creation finished' do
            let(:runner_traits) { [] }

            it_behaves_like 'a protected ephemeral_authentication_token'
          end
        end

        context 'with runner created almost too long ago' do
          let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }

          it_behaves_like 'an ephemeral_authentication_token'
        end

        context 'with runner created too long ago' do
          let(:runner_traits) { [:unregistered, :created_after_registration_deadline] }

          it_behaves_like 'a protected ephemeral_authentication_token'
        end
      end

      context 'with runner registered from command line' do
        let(:registration_type) { :registration_token }

        context 'with runner created within registration deadline' do
          let(:runner_traits) { [:created_before_registration_deadline] }

          it_behaves_like 'a protected ephemeral_authentication_token'
        end
      end
    end

    context 'when request is made by non-creator of the runner' do
      let(:user) { create(:admin) }

      context 'with runner created in UI' do
        let(:registration_type) { :authenticated_user }
        let(:runner_traits) { [:unregistered, :created_before_registration_deadline] }

        it_behaves_like 'a protected ephemeral_authentication_token'
      end
    end
  end

  describe 'Query limits' do
    let_it_be(:user2) { another_admin }
    let_it_be(:user3) { create(:user) }
    let_it_be(:tag_list) { %w[n_plus_1_test some_tag] }
    let_it_be(:args) do
      { current_user: user, token: { personal_access_token: create(:personal_access_token, user: user) } }
    end

    let_it_be(:runner1) { create(:ci_runner, tag_list: tag_list, creator: user) }
    let_it_be(:runner2) do
      create(:ci_runner, :group, groups: [group], tag_list: tag_list, creator: user)
    end

    let_it_be(:runner3) do
      create(:ci_runner, :project, projects: [project1], tag_list: tag_list, creator: user)
    end

    let(:single_discrete_runners_query) do
      multiple_discrete_runners_query([])
    end

    let(:runner_fragment) do
      <<~QUERY
        #{all_graphql_fields_for('CiRunner', excluded: excluded_fields)}
        createdBy {
          id
          username
          webPath
          webUrl
        }
      QUERY
    end

    # Exclude fields that are already hardcoded above (or tested separately),
    #   and also some fields from deeper objects which are problematic:
    # - createdBy: Known N+1 issues, but only on exotic fields which we don't normally use
    # - ownerProject.pipeline: Needs arguments (iid or sha)
    # - project.productAnalyticsState: Can be requested only for 1 Project(s) at a time.
    # - project.mergeTrains: Is a licensed feature
    let(:excluded_fields) { %w[createdBy jobs pipeline productAnalyticsState mergeTrains] }

    it 'avoids N+1 queries', :use_sql_query_cache do
      discrete_runners_control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
        post_graphql(single_discrete_runners_query, **args)
      end

      additional_runners = setup_additional_records

      expect do
        post_graphql(multiple_discrete_runners_query(additional_runners), **args)

        raise StandardError, flattened_errors if graphql_errors # Ensure any error in query causes test to fail
      end.not_to exceed_query_limit(discrete_runners_control)
    end

    def runner_query(runner, nr)
      <<~QUERY
        runner#{nr}: runner(id: "#{runner.to_global_id}") {
          #{runner_fragment}
        }
      QUERY
    end

    def multiple_discrete_runners_query(additional_runners)
      <<~QUERY
        {
          #{runner_query(runner1, 1)}
          #{runner_query(runner2, 2)}
          #{runner_query(runner3, 3)}
          #{additional_runners.each_with_index.map { |r, i| runner_query(r, 4 + i) }.join("\n")}
        }
      QUERY
    end

    def setup_additional_records
      # Add more runners (including owned by other users)
      runner4 = create(:ci_runner, tag_list: tag_list + %w[tag1 tag2], creator: user2)
      runner5 = create(:ci_runner, :group, groups: [create(:group)], tag_list: tag_list + %w[tag2 tag3], creator: user3)
      # Add one more project to runner
      runner3.assign_to(create(:project))

      # Add more runner managers (including to existing runners)
      runner_manager1 = create(:ci_runner_machine, runner: runner1)
      create(:ci_runner_machine, runner: runner1)
      create(:ci_runner_machine, runner: runner2, system_xid: runner_manager1.system_xid)
      create(:ci_runner_machine, runner: runner3)
      create(:ci_runner_machine, runner: runner4, version: '16.4.1')
      create(:ci_runner_machine, runner: runner5, version: '16.4.0', system_xid: runner_manager1.system_xid)
      create(:ci_runner_machine, runner: runner3)

      [runner4, runner5]
    end
  end

  describe 'Query limits with jobs' do
    let_it_be(:group1) { create(:group) }
    let_it_be(:group2) { create(:group) }
    let_it_be(:project1) { create(:project, :repository, group: group1) }
    let_it_be(:project2) { create(:project, :repository, group: group1) }
    let_it_be(:project3) { create(:project, :repository, group: group2) }

    let_it_be(:merge_request1) { create(:merge_request, source_project: project1) }
    let_it_be(:merge_request2) { create(:merge_request, source_project: project3) }

    let(:project_runner2) { create(:ci_runner, :project, projects: [project1, project2]) }
    let!(:build1) { create(:ci_build, :success, name: 'Build One', runner: project_runner2, pipeline: pipeline1) }
    let_it_be(:pipeline1) do
      create(
        :ci_pipeline,
        project: project1,
        source: :merge_request_event,
        merge_request: merge_request1,
        ref: 'main',
        target_sha: 'xxx'
      )
    end

    let(:query) do
      <<~QUERY
        {
          runner(id: "#{project_runner2.to_global_id}") {
            id
            jobs {
              nodes {
                id
                #{field}
              }
            }
          }
        }
      QUERY
    end

    context 'when requesting individual fields' do
      where(:field) do
        [
          'detailedStatus { id detailsPath group icon text }',
          'project { id name webUrl }'
        ] + %w[
          shortSha
          browseArtifactsPath
          commitPath
          playPath
          refPath
          webPath
          finishedAt
          duration
          queuedDuration
          tags
        ]
      end

      with_them do
        it 'does not execute more queries per job', :use_sql_query_cache, :aggregate_failures do
          admin2 = create(:user, :admin) # do not reuse same user

          # warm-up license cache and so on:
          personal_access_token = create(:personal_access_token, user: user)
          personal_access_token2 = create(:personal_access_token, user: admin2)
          args = { current_user: user, token: { personal_access_token: personal_access_token } }
          args2 = { current_user: admin2, token: { personal_access_token: personal_access_token2 } }
          post_graphql(query, **args2)

          control = ActiveRecord::QueryRecorder.new(skip_cached: false) { post_graphql(query, **args) }

          # Add a new build to project_runner2
          project_runner2.runner_projects << build(:ci_runner_project, runner: project_runner2, project: project3)
          pipeline2 = create(
            :ci_pipeline,
            project: project3,
            source: :merge_request_event,
            merge_request: merge_request2,
            ref: 'main',
            target_sha: 'xxx'
          )
          build2 = create(:ci_build, :success, name: 'Build Two', runner: project_runner2, pipeline: pipeline2)

          expect { post_graphql(query, **args2) }.not_to exceed_all_query_limit(control)

          expect(graphql_data.count).to eq 1
          expect(graphql_data).to match(
            a_hash_including(
              'runner' => a_graphql_entity_for(
                project_runner2,
                jobs: { 'nodes' => containing_exactly(a_graphql_entity_for(build1), a_graphql_entity_for(build2)) }
              )
            ))
        end
      end
    end
  end

  describe 'sorting and pagination' do
    let(:query) do
      <<~GQL
        query($id: CiRunnerID!, $projectSearchTerm: String, $n: Int, $cursor: String) {
          runner(id: $id) {
            #{fields}
          }
        }
      GQL
    end

    before do
      post_graphql(query, current_user: user, variables: variables)
    end

    context 'with project search term' do
      let_it_be(:project1) { create(:project, description: 'abc') }
      let_it_be(:project2) { create(:project, description: 'def') }
      let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project1, project2]) }

      let(:variables) { { id: project_runner.to_global_id.to_s, n: n, project_search_term: search_term } }

      let(:fields) do
        <<~QUERY
          projects(search: $projectSearchTerm, first: $n, after: $cursor) {
            count
            nodes {
              id
            }
            pageInfo {
              hasPreviousPage
              startCursor
              endCursor
              hasNextPage
            }
          }
        QUERY
      end

      let(:projects_data) { graphql_data_at('runner', 'projects') }

      context 'set to empty string' do
        let(:search_term) { '' }

        context 'with n = 1' do
          let(:n) { 1 }

          it_behaves_like 'a working graphql query'

          it 'returns paged result' do
            expect(projects_data).not_to be_nil
            expect(projects_data['count']).to eq 2
            expect(projects_data['pageInfo']['hasNextPage']).to eq true
          end
        end

        context 'with n = 2' do
          let(:n) { 2 }

          it 'returns non-paged result' do
            expect(projects_data).not_to be_nil
            expect(projects_data['count']).to eq 2
            expect(projects_data['pageInfo']['hasNextPage']).to eq false
          end
        end
      end

      context 'set to partial match' do
        let(:search_term) { 'def' }

        context 'with n = 1' do
          let(:n) { 1 }

          it_behaves_like 'a working graphql query'

          it 'returns paged result with no additional pages' do
            expect(projects_data).not_to be_nil
            expect(projects_data['count']).to eq 1
            expect(projects_data['pageInfo']['hasNextPage']).to eq false
          end
        end
      end
    end
  end
end
