8.2 KiB · text History b868907
defmodule GitGud.Workflows.UserRunnersTest do
  @moduledoc """
  A user's own runners (`runners.owner_id`): registered with a token
  from /users/settings/runners, serving the repositories that user owns
  personally — never an organization's, though an org repository's
  `owner_id` is its creator's.
  """
  use GitGud.DataCase, async: false

  alias GitGud.Accounts.User
  alias GitGud.Organizations
  alias GitGud.Repositories
  alias GitGud.Workflows
  alias GitGud.Workflows.Runner
  alias GitGud.Workflows.Runners
  alias GitGud.Workflows.WorkflowJob
  alias GitGud.Workflows.WorkflowRun
  alias GitGud.Workflows.Yaml

  import GitGud.AccountsFixtures
  import GitGud.ForgeFixtures

  setup do
    {user, repo} = repository_fixture()
    {:ok, user: user, repo: repo}
  end

  # A queued job in `repo`, through `rerun/1`'s creation path (as
  # MatrixDispatchTest does): a seed row and a re-run, no git tree.
  defp queued_job(repo) do
    {:ok, doc} =
      Yaml.parse("""
      name: ci
      on: push
      jobs:
        build:
          runs-on: ubuntu-latest
          steps:
            - run: true
      """)

    seed =
      %WorkflowRun{repository_id: repo.id}
      |> WorkflowRun.create_changeset(%{
        "number" => System.unique_integer([:positive]),
        "workflow_path" => ".forgejo/workflows/ci.yml",
        "workflow_name" => doc.name,
        "trigger" => "push",
        "head_ref" => "refs/heads/main",
        "head_sha" => :crypto.strong_rand_bytes(20),
        "workflow_doc" => doc.raw
      })
      |> Repo.insert!()

    {:ok, run} = Workflows.rerun(seed)
    [job] = Repo.all(from j in WorkflowJob, where: j.workflow_run_id == ^run.id)
    job
  end

  defp runner(scope \\ :global) do
    {:ok, runner, _uuid, _token} =
      Runners.create_offline(%{name: "r-#{System.unique_integer([:positive])}"}, scope)

    runner
  end

  defp org_with_repo(admin) do
    handle = "o#{System.unique_integer([:positive])}"
    {:ok, org} = Organizations.create_organization(admin, %{"handle" => handle, "name" => handle})

    {:ok, repo} =
      Repositories.create_repository_for_org(org, admin, %{
        "name" => unique_repo_name(),
        "visibility" => "private"
      })

    {org, repo}
  end

  describe "registration" do
    test "a user's token registers one runner of theirs", %{user: user} do
      {:ok, token} = Runners.mint_registration_token(user)
      assert Runners.has_token?(Repo.reload!(user))

      assert {:ok, runner, bearer} = Runners.register_with_token(token, %{name: "laptop"})
      assert is_binary(bearer)
      assert runner.owner_id == user.id
      assert is_nil(runner.repository_id) and is_nil(runner.organization_id)
      assert Runner.scope_label(runner) == "user"

      assert :error = Runners.register_with_token(token, %{name: "again"})
      refute Runners.has_token?(Repo.reload!(user))
    end

    test "consuming resolves to the user", %{user: user} do
      {:ok, token} = Runners.mint_registration_token(user)
      assert {:ok, %User{id: id}} = Runners.consume_registration_token(token)
      assert id == user.id
      assert :error = Runners.consume_registration_token(token)
    end

    test "a user's token expires", %{user: user} do
      {:ok, token} = Runners.mint_registration_token(user)

      user
      |> Repo.reload!()
      |> Ecto.Changeset.change(
        runner_reg_token_expires_at: DateTime.add(DateTime.utc_now(:second), -1)
      )
      |> Repo.update!()

      assert :error = Runners.register_with_token(token, %{name: "late"})
    end

    test "regenerating invalidates the prior token", %{user: user} do
      {:ok, t1} = Runners.mint_registration_token(user)
      {:ok, t2} = Runners.mint_registration_token(user)

      assert :error = Runners.consume_registration_token(t1)
      assert {:ok, %User{}} = Runners.consume_registration_token(t2)
    end

    test "registration is audited in the user's own log", %{user: user} do
      {:ok, token} = Runners.mint_registration_token(user)

      {:ok, runner, _} =
        Runners.register_with_token(token, %{name: "laptop"}, audit: {127, 0, 0, 1})

      assert [entry] = Repo.all(GitGud.Audit.Entry)
      assert {entry.action, entry.target_id} == {"runner.registered", runner.id}
      assert entry.subject_user_id == user.id
      assert is_nil(entry.repository_id) and is_nil(entry.organization_id)
    end

    test "offline: create_offline/2 with a user", %{user: user} do
      assert %Runner{owner_id: uid} = runner(user)
      assert uid == user.id
    end

    test "a runner can't be in two scopes", %{user: user, repo: repo} do
      assert {:error, cs} =
               %Runner{token_hash: :crypto.strong_rand_bytes(32), uuid: Ecto.UUID.generate()}
               |> Runner.changeset(%{name: "both", repository_id: repo.id, owner_id: user.id})
               |> Repo.insert()

      assert {"runner may be scoped to one of a user, a repository or an organization", _} =
               cs.errors[:base]
    end
  end

  describe "list_for_scope/1" do
    test "a user sees their own and global runners, not someone else's", %{user: user} do
      own = runner(user)
      global = runner()
      _other = runner(user_fixture())
      {_, repo} = repository_fixture()
      _repo_scoped = runner(repo)

      assert Runners.list_for_scope(user) |> Enum.map(& &1.id) |> Enum.sort() ==
               Enum.sort([own.id, global.id])
    end

    test "a personal repository inherits its owner's runners", %{user: user, repo: repo} do
      own = runner(user)
      global = runner()
      repo_scoped = runner(repo)
      _other = runner(user_fixture())

      assert Runners.list_for_scope(repo) |> Enum.map(& &1.id) |> Enum.sort() ==
               Enum.sort([own.id, global.id, repo_scoped.id])
    end

    test "an org repository doesn't, though its creator is its owner_id", %{user: user} do
      {org, org_repo} = org_with_repo(user)
      _own = runner(user)
      org_runner = runner(org)
      global = runner()

      assert org_repo.owner_id == user.id

      assert Runners.list_for_scope(org_repo) |> Enum.map(& &1.id) |> Enum.sort() ==
               Enum.sort([org_runner.id, global.id])
    end

    test ":global leaves a user's runner out", %{user: user} do
      _own = runner(user)
      global = runner()
      assert [%Runner{id: id}] = Runners.list_for_scope(:global)
      assert id == global.id
    end
  end

  describe "claiming" do
    test "a user's runner takes a job in their personal repository", %{user: user, repo: repo} do
      job = queued_job(repo)
      assert %WorkflowJob{id: id} = Workflows.claim_next_job(runner(user))
      assert id == job.id
    end

    test "not one in another user's repository", %{user: user} do
      {_, theirs} = repository_fixture()
      _job = queued_job(theirs)

      assert Workflows.claim_next_job(runner(user)) == nil
    end

    test "not one in an org repository the user created and belongs to", %{user: user} do
      {_org, org_repo} = org_with_repo(user)
      job = queued_job(org_repo)

      assert Workflows.claim_next_job(runner(user)) == nil
      # Nor when asked for that job by id.
      assert Workflows.claim_job_by_handle(runner(user), to_string(job.id)) == nil
    end

    test "not an unapproved fork run's", %{user: user, repo: repo} do
      job = queued_job(repo)

      Repo.update_all(from(r in WorkflowRun, where: r.id == ^job.workflow_run_id),
        set: [fork: true, approved_at: nil]
      )

      assert Workflows.claim_next_job(runner(user)) == nil
    end

    test "a global runner still takes anything", %{user: user, repo: repo} do
      {_org, org_repo} = org_with_repo(user)
      {_, theirs} = repository_fixture()
      jobs = Enum.map([repo, org_repo, theirs], &queued_job/1)
      global = runner()

      claimed = for _ <- jobs, do: Workflows.claim_next_job(global).id
      assert Enum.sort(claimed) == Enum.sort(Enum.map(jobs, & &1.id))
    end

    test "repo and org runners keep to their own", %{user: user, repo: repo} do
      {org, org_repo} = org_with_repo(user)
      repo_job = queued_job(repo)
      org_job = queued_job(org_repo)

      assert Workflows.claim_next_job(runner(org)).id == org_job.id
      assert Workflows.claim_next_job(runner(repo)).id == repo_job.id
      assert Workflows.claim_next_job(runner(org)) == nil
      assert Workflows.claim_next_job(runner(repo)) == nil
    end
  end
end