defmodule TarakanWeb.AccountLive.SettingsTest do use TarakanWeb.ConnCase, async: true alias Tarakan.Accounts import Phoenix.LiveViewTest import Tarakan.AccountsFixtures describe "Settings page" do test "opens on the account panel", %{conn: conn} do {:ok, view, html} = conn |> log_in_account(account_fixture()) |> live(~p"/accounts/settings") assert html =~ "Save email" assert html =~ "Save password" assert html =~ "Connected code hosts" assert html =~ "GitHub" assert html =~ "GitLab" assert has_element?(view, "#settings-tab-account[aria-current=page]") refute has_element?(view, "#api-reference") refute has_element?(view, "#ssh-key-form") end test "moves between panels from the rail", %{conn: conn} do {:ok, view, _html} = conn |> log_in_account(account_fixture()) |> live(~p"/accounts/settings") html = view |> element("#settings-tab-credentials") |> render_click() assert html =~ "Client credentials" assert_patch(view, ~p"/accounts/settings/credentials") assert has_element?(view, "#settings-tab-credentials[aria-current=page]") refute has_element?(view, "#email_form") view |> element("#settings-tab-ssh") |> render_click() assert_patch(view, ~p"/accounts/settings/ssh-keys") assert has_element?(view, "#ssh-key-form") view |> element("#settings-tab-agents") |> render_click() assert_patch(view, ~p"/accounts/settings/agents") assert has_element?(view, "#settings-model-scoreboard", "None yet") end test "an unknown panel slug falls back to the account panel", %{conn: conn} do {:ok, view, _html} = conn |> log_in_account(account_fixture()) |> live(~p"/accounts/settings/nonsense") assert has_element?(view, "#email_form") assert has_element?(view, "#settings-tab-account[aria-current=page]") end test "renders the API reference on the credentials panel", %{conn: conn} do {:ok, view, html} = conn |> log_in_account(account_fixture()) |> live(~p"/accounts/settings/credentials") assert has_element?(view, "#api-reference", "API reference") assert has_element?(view, "#api-reference-base-url", "/api") assert html =~ "/client-auth/exchange" assert html =~ "/repositories" assert html =~ "/jobs/:id/claim/renew" assert html =~ "/:host/:owner/:name/reports" end test "marks an attached code-host identity as connected", %{conn: conn} do {:ok, view, _html} = conn |> log_in_account(github_account_fixture()) |> live(~p"/accounts/settings") assert has_element?(view, "#settings-github-identity", "Connected") assert has_element?(view, "#settings-gitlab-identity", "Connect") end test "redirects if account is not logged in", %{conn: conn} do assert {:error, redirect} = live(conn, ~p"/accounts/settings") assert {:redirect, %{to: path, flash: flash}} = redirect assert path == ~p"/accounts/log-in" assert %{"error" => "You must log in to access this page."} = flash end test "generates and independently revokes a client credential", %{conn: conn} do account = account_fixture() {:ok, view, html} = conn |> log_in_account(account) |> live(~p"/accounts/settings/credentials") assert html =~ "Client credentials" refute has_element?(view, "#api-token-value") view |> form("#api-credential-form", %{ "credential" => %{ "name" => "Task reader", "scopes" => ["tasks:read"] } }) |> render_submit() token = view |> element("#api-token-value") |> render() |> String.replace(~r/<[^>]+>/, "") |> String.trim() assert {:ok, fetched} = Accounts.fetch_account_by_api_token(token) assert fetched.id == account.id [credential] = Tarakan.Accounts.ApiCredentials.list(account) assert credential.name == "Task reader" assert credential.scopes == ["tasks:read"] assert has_element?(view, "#api-credential-#{credential.id}") view |> element("#revoke-api-credential-#{credential.id}") |> render_click() assert :error = Accounts.fetch_account_by_api_token(token) refute has_element?(view, "#revoke-api-credential-#{credential.id}") end test "can bind a least-privilege credential to one repository", %{conn: conn} do account = github_account_fixture() repository = github_repository_fixture(account) {:ok, view, _html} = conn |> log_in_account(account) |> live(~p"/accounts/settings/credentials") view |> form("#api-credential-form", %{ "credential" => %{ "name" => "Codex reader", "repository" => "openai/codex", "scopes" => ["tasks:read"] } }) |> render_submit() token = view |> element("#api-token-value") |> render() |> String.replace(~r/<[^>]+>/, "") |> String.trim() assert {:ok, ^account, credential} = Tarakan.Accounts.ApiCredentials.authenticate(token) assert credential.repository_id == repository.id assert credential.scopes == ["tasks:read"] end test "redirects if account is not in sudo mode", %{conn: conn} do stale_at = DateTime.add(DateTime.utc_now(:second), -9 * 60, :minute) {:ok, conn} = conn |> log_in_account(account_fixture(), token_authenticated_at: stale_at) |> live(~p"/accounts/settings") |> follow_redirect(conn, ~p"/accounts/log-in?return_to=%2Faccounts%2Fsettings") assert conn.resp_body =~ "sign-in older than 2 hours for sensitive settings" end test "prompts unconfirmed accounts to confirm email instead of credential forms", %{ conn: conn } do conn = log_in_account(conn, unconfirmed_account_fixture()) {:ok, view, html} = live(conn, ~p"/accounts/settings") assert has_element?(view, "#email-confirmation-required") assert has_element?(view, "#settings-tab-account", "unconfirmed") assert html =~ "Confirm your email to unlock credentials" refute has_element?(view, "#password_form") {:ok, view, _html} = live(conn, ~p"/accounts/settings/credentials") refute has_element?(view, "#api-credential-form") {:ok, view, _html} = live(conn, ~p"/accounts/settings/ssh-keys") refute has_element?(view, "#ssh-key-form") end end describe "SSH keys" do @describetag :tmp_dir defp generate_public_key(tmp_dir) do path = Path.join(tmp_dir, "key-#{System.unique_integer([:positive])}") {_output, 0} = System.cmd("ssh-keygen", ["-t", "ed25519", "-f", path, "-N", "", "-q"]) File.read!(path <> ".pub") end test "adds and removes a key", %{conn: conn, tmp_dir: tmp_dir} do account = account_fixture() {:ok, view, html} = conn |> log_in_account(account) |> live(~p"/accounts/settings/ssh-keys") assert html =~ "SSH keys" view |> form("#ssh-key-form", ssh_key: %{name: "laptop", public_key: generate_public_key(tmp_dir)} ) |> render_submit() assert has_element?(view, "#ssh-key-list", "laptop") assert has_element?(view, "#ssh-key-list", "SHA256:") [key] = Tarakan.Accounts.SshKeys.list_for_account(account) view |> element("#delete-ssh-key-#{key.id}") |> render_click() refute has_element?(view, "#ssh-key-list") assert Tarakan.Accounts.SshKeys.list_for_account(account) == [] end test "rejects an invalid key inline", %{conn: conn} do {:ok, view, _html} = conn |> log_in_account(account_fixture()) |> live(~p"/accounts/settings/ssh-keys") html = view |> form("#ssh-key-form", ssh_key: %{name: "junk", public_key: "not a key"}) |> render_submit() assert html =~ "is not a supported OpenSSH public key" end end describe "update email form" do setup %{conn: conn} do account = account_fixture() %{conn: log_in_account(conn, account), account: account} end test "updates the account email", %{conn: conn, account: account} do new_email = unique_account_email() {:ok, lv, _html} = live(conn, ~p"/accounts/settings") result = lv |> form("#email_form", %{ "account" => %{"email" => new_email} }) |> render_submit() assert result =~ "A link to confirm your email" assert Accounts.get_account_by_email(account.email) end test "renders errors with invalid data (phx-change)", %{conn: conn} do {:ok, lv, _html} = live(conn, ~p"/accounts/settings") result = lv |> element("#email_form") |> render_change(%{ "action" => "update_email", "account" => %{"email" => "with spaces"} }) assert result =~ "Save email" assert result =~ "must have the @ sign and no spaces" end test "renders errors with invalid data (phx-submit)", %{conn: conn, account: account} do {:ok, lv, _html} = live(conn, ~p"/accounts/settings") result = lv |> form("#email_form", %{ "account" => %{"email" => account.email} }) |> render_submit() assert result =~ "Save email" assert result =~ "did not change" end end describe "update password form" do setup %{conn: conn} do account = account_fixture() %{conn: log_in_account(conn, account), account: account} end test "updates the account password", %{conn: conn, account: account} do new_password = valid_account_password() {:ok, lv, _html} = live(conn, ~p"/accounts/settings") form = form(lv, "#password_form", %{ "account" => %{ "password" => new_password, "password_confirmation" => new_password } }) render_submit(form) new_password_conn = follow_trigger_action(form, conn) assert redirected_to(new_password_conn) == ~p"/accounts/settings" assert get_session(new_password_conn, :account_token) != get_session(conn, :account_token) assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ "Password updated successfully" assert Accounts.get_account_by_email_and_password(account.email, new_password) end test "renders errors with invalid data (phx-change)", %{conn: conn} do {:ok, lv, _html} = live(conn, ~p"/accounts/settings") result = lv |> element("#password_form") |> render_change(%{ "account" => %{ "password" => "too short", "password_confirmation" => "does not match" } }) assert result =~ "Save password" assert result =~ "should be at least 15 character(s)" assert result =~ "does not match password" end test "renders errors with invalid data (phx-submit)", %{conn: conn} do {:ok, lv, _html} = live(conn, ~p"/accounts/settings") result = lv |> form("#password_form", %{ "account" => %{ "password" => "too short", "password_confirmation" => "does not match" } }) |> render_submit() assert result =~ "Save password" assert result =~ "should be at least 15 character(s)" assert result =~ "does not match password" end end describe "confirm email" do setup %{conn: conn} do account = account_fixture() email = unique_account_email() token = extract_account_token(fn url -> Accounts.deliver_account_update_email_instructions( %{account | email: email}, account.email, url ) end) %{conn: log_in_account(conn, account), token: token, email: email, account: account} end test "updates the account email once", %{ conn: conn, account: account, token: token, email: email } do {:error, redirect} = live(conn, ~p"/accounts/settings/confirm-email/#{token}") assert {:live_redirect, %{to: path, flash: flash}} = redirect assert path == ~p"/accounts/settings" assert %{"info" => message} = flash assert message == "Email changed successfully." refute Accounts.get_account_by_email(account.email) assert Accounts.get_account_by_email(email) # use confirm token again {:error, redirect} = live(conn, ~p"/accounts/settings/confirm-email/#{token}") assert {:live_redirect, %{to: path, flash: flash}} = redirect assert path == ~p"/accounts/settings" assert %{"error" => message} = flash assert message == "Email change link is invalid or it has expired." end test "does not update email with invalid token", %{conn: conn, account: account} do {:error, redirect} = live(conn, ~p"/accounts/settings/confirm-email/oops") assert {:live_redirect, %{to: path, flash: flash}} = redirect assert path == ~p"/accounts/settings" assert %{"error" => message} = flash assert message == "Email change link is invalid or it has expired." assert Accounts.get_account_by_email(account.email) end test "redirects if account is not logged in", %{token: token} do conn = build_conn() {:error, redirect} = live(conn, ~p"/accounts/settings/confirm-email/#{token}") assert {:redirect, %{to: path, flash: flash}} = redirect assert path == ~p"/accounts/log-in" assert %{"error" => message} = flash assert message == "You must log in to access this page." end end end