4.9 KiB · text History b868907
defmodule GitGudWeb.ApiTokenScopesTest do
  @moduledoc """
  The API tokens page, with the per-surface scopes and the "Mobile app"
  and "Desktop widget" bundle toggles.

  The bundle is the part worth testing: it's not a scope, it's a
  checkbox that ticks a set of them, and the pairing QR only appears
  when the minted token actually covers that set. Getting either half
  wrong produces a device that pairs and then fails on every screen.
  """
  use GitGudWeb.ConnCase, async: false

  import Phoenix.LiveViewTest
  import GitGud.AccountsFixtures

  alias GitGud.Accounts
  alias GitGud.Accounts.PersonalAccessToken

  setup %{conn: conn} do
    user = user_fixture()
    %{conn: log_in_user(conn, user), user: user}
  end

  test "lists every scope with its description", %{conn: conn} do
    {:ok, _lv, html} = live(conn, ~p"/users/settings/tokens")

    for scope <- PersonalAccessToken.scopes() do
      assert html =~ scope
    end

    assert html =~ "Read workflow runs, jobs and logs"
    assert html =~ "Re-run and cancel workflow runs"
  end

  test "the mobile toggle ticks and unticks the whole bundle", %{conn: conn, user: user} do
    {:ok, lv, _html} = live(conn, ~p"/users/settings/tokens")

    lv |> element(~s{input[phx-value-bundle="mobile"]}) |> render_click()
    lv |> form("form[phx-submit=create_token]", %{"name" => "phone"}) |> render_submit()

    token = Enum.find(Accounts.list_personal_access_tokens(user), &(&1.name == "phone"))
    assert PersonalAccessToken.mobile?(token.scopes)

    # Untoggling clears the bundle but leaves the default `repo` scope,
    # which the user picked separately.
    lv |> element(~s{input[phx-value-bundle="mobile"]}) |> render_click()
    lv |> form("form[phx-submit=create_token]", %{"name" => "script"}) |> render_submit()

    script = Enum.find(Accounts.list_personal_access_tokens(user), &(&1.name == "script"))
    assert script.scopes == ["repo"]
  end

  test "renders the pairing QR only for a token covering the bundle", %{conn: conn} do
    {:ok, lv, _html} = live(conn, ~p"/users/settings/tokens")

    plain = lv |> form("form[phx-submit=create_token]", %{"name" => "script"}) |> render_submit()
    refute plain =~ "gitgud://pair"

    lv |> element(~s{input[phx-value-bundle="mobile"]}) |> render_click()
    mobile = lv |> form("form[phx-submit=create_token]", %{"name" => "phone"}) |> render_submit()

    assert mobile =~ "Pair a device"
    assert mobile =~ "gitgud://pair"
    # An <svg> QR of the same URI, not just the text fallback.
    assert mobile =~ "<svg"
  end

  test "the desktop toggle ticks exactly the widget's scopes", %{conn: conn, user: user} do
    {:ok, lv, _html} = live(conn, ~p"/users/settings/tokens")

    # Drop the default `repo` so the token carries the bundle and nothing else.
    lv |> element(~s{input[phx-value-scope="repo"]}) |> render_click()
    lv |> element(~s{input[phx-value-bundle="desktop"]}) |> render_click()
    html = lv |> form("form[phx-submit=create_token]", %{"name" => "panel"}) |> render_submit()

    token = Enum.find(Accounts.list_personal_access_tokens(user), &(&1.name == "panel"))
    assert Enum.sort(token.scopes) == Enum.sort(PersonalAccessToken.desktop_scopes())
    assert PersonalAccessToken.desktop?(token.scopes)
    refute PersonalAccessToken.mobile?(token.scopes)

    # The widget hint shows; the phone pairing QR doesn't.
    assert html =~ "GitGud CI widget"
    refute html =~ "gitgud://pair"
  end

  test "unticking desktop keeps scopes picked separately", %{conn: conn, user: user} do
    {:ok, lv, _html} = live(conn, ~p"/users/settings/tokens")

    lv |> element(~s{input[phx-value-bundle="desktop"]}) |> render_click()
    lv |> element(~s{input[phx-value-scope="issues:read"]}) |> render_click()
    lv |> element(~s{input[phx-value-bundle="desktop"]}) |> render_click()
    lv |> form("form[phx-submit=create_token]", %{"name" => "mixed"}) |> render_submit()

    token = Enum.find(Accounts.list_personal_access_tokens(user), &(&1.name == "mixed"))
    assert Enum.sort(token.scopes) == ["issues:read", "repo"]
  end

  test "a mobile token also covers the desktop bundle" do
    assert PersonalAccessToken.desktop?(PersonalAccessToken.mobile_scopes())
  end

  test "an individual scope can be toggled on its own", %{conn: conn, user: user} do
    {:ok, lv, _html} = live(conn, ~p"/users/settings/tokens")

    lv |> element(~s{input[phx-value-scope="ci:read"]}) |> render_click()
    lv |> form("form[phx-submit=create_token]", %{"name" => "dashboard"}) |> render_submit()

    token = Enum.find(Accounts.list_personal_access_tokens(user), &(&1.name == "dashboard"))
    assert Enum.sort(token.scopes) == ["ci:read", "repo"]
  end

  test "refuses a token with no scopes at all", %{conn: conn} do
    {:ok, lv, _html} = live(conn, ~p"/users/settings/tokens")

    lv |> element(~s{input[phx-value-scope="repo"]}) |> render_click()
    html = lv |> form("form[phx-submit=create_token]", %{"name" => "empty"}) |> render_submit()

    assert html =~ "Pick at least one scope"
  end
end