test/openagents/accounts_test.exs

58e6347eeb72 · 6 KB

defmodule OpenAgents.AccountsTest do
  use OpenAgents.DataCase, async: false
  alias OpenAgents.Accounts
  alias OpenAgents.Conversations

  test "GitHub numeric identity is stable while login and avatar projections refresh" do
    assert {:ok, first} = Accounts.upsert_github_user(profile(91, "first-name"))

    assert {:ok, refreshed} =
             Accounts.upsert_github_user(
               profile(91, "renamed", "https://avatars.githubusercontent.com/u/91?v=5")
             )

    assert refreshed.id == first.id
    assert refreshed.github_id == 91
    assert refreshed.github_login == "renamed"
    assert refreshed.github_avatar_url == "https://avatars.githubusercontent.com/u/91?v=5"

    assert {:ok, other} = Accounts.upsert_github_user(profile(92, "other-name"))
    refute other.id == first.id
  end

  test "a ban survives GitHub profile refresh and fails active authorization closed" do
    assert {:ok, user} = Accounts.upsert_github_user(profile(101, "active-name"))
    assert {:ok, banned} = Accounts.ban_user(user, "manual_abuse_review")
    assert banned.status == "banned"
    assert banned.banned_at

    assert {:ok, refreshed} = Accounts.upsert_github_user(profile(101, "renamed-after-ban"))
    assert refreshed.id == user.id
    assert refreshed.github_login == "renamed-after-ban"
    assert refreshed.status == "banned"
    assert {:error, :banned} = Accounts.get_active_user(user.id)
  end

  test "one account owns one canonical conversation across browser sessions" do
    assert {:ok, user} = Accounts.upsert_github_user(profile(201, "same-person"))
    assert {:ok, first} = Conversations.ensure_conversation(user)
    assert {:ok, second} = Conversations.ensure_conversation(user)
    assert first.id == second.id

    assert {:ok, other} = Accounts.upsert_github_user(profile(202, "other-person"))
    assert {:ok, isolated} = Conversations.ensure_conversation(other)
    refute isolated.id == first.id

    assert Conversations.get_conversation_for_user(user).id == first.id
    assert Conversations.get_conversation_for_user(other).id == isolated.id
  end

  test "account-scoped turn limits aggregate across sessions sharing the user" do
    previous_limit = Application.fetch_env!(:openagents, :turn_rate_limit)
    Application.put_env(:openagents, :turn_rate_limit, 1)
    on_exit(fn -> Application.put_env(:openagents, :turn_rate_limit, previous_limit) end)

    assert {:ok, user} = Accounts.upsert_github_user(profile(301, "limited-person"))
    assert {:ok, first_browser_conversation} = Conversations.ensure_conversation(user)
    assert {:ok, _records} = Conversations.create_turn(first_browser_conversation, "First")

    assert {:ok, second_browser_conversation} = Conversations.ensure_conversation(user)
    assert second_browser_conversation.id == first_browser_conversation.id

    assert {:error, :rate_limited} =
             Conversations.create_turn(second_browser_conversation, "Second")
  end

  test "legacy browser rows remain isolated from authenticated accounts" do
    assert {:ok, legacy} = Conversations.ensure_conversation("legacy-browser-credential")
    assert {:ok, user} = Accounts.upsert_github_user(profile(401, "new-account"))
    assert {:ok, authenticated} = Conversations.ensure_conversation(user)

    refute authenticated.id == legacy.id
    assert Conversations.get_conversation_for_user(user).id == authenticated.id
    assert Conversations.get_conversation_for_browser("legacy-browser-credential").id == legacy.id
  end

  test "retained GitHub grants can be rewrapped and disconnected without deleting identity" do
    assert {:ok, user} = Accounts.upsert_github_user(profile(501, "token-owner"))

    assert {:error, :invalid_token_scopes} =
             Accounts.store_github_token(user, "gho_too_broad", ["user:email", "repo"])

    assert {:ok, connected} = Accounts.store_github_token(user, "gho_retained")
    assert connected.github_token_key_id == "test-2026-08"

    assert {:ok, rotated} = Accounts.rotate_github_token(connected)
    assert rotated.github_token_rotated_at
    assert {:ok, "gho_retained"} = Accounts.github_token(rotated)

    assert {:ok, disconnected} =
             Accounts.disconnect_github(rotated, fn token ->
               assert token == "gho_retained"
               :ok
             end)

    assert disconnected.id == user.id

    assert {:ok, repository_user} =
             Accounts.upsert_github_user(profile(502, "repository-token-owner"))

    assert {:ok, _repository_grant} =
             Accounts.store_github_token(repository_user, "gho_repository", ["repo", "read:org"])

    assert disconnected.github_token_ciphertext == nil
    assert disconnected.github_token_scopes == []
    assert {:error, :github_token_missing} = Accounts.github_token(disconnected)
  end

  test "disconnecting a stale envelope never clears a concurrently replaced grant" do
    assert {:ok, user} = Accounts.upsert_github_user(profile(502, "token-race-owner"))
    assert {:ok, old_connection} = Accounts.store_github_token(user, "gho_old")
    assert {:ok, new_connection} = Accounts.store_github_token(old_connection, "gho_new")

    assert {:error, :github_connection_changed} =
             Accounts.disconnect_github(old_connection, fn token ->
               assert token == "gho_old"
               :ok
             end)

    retained = Accounts.get_user(user.id)
    assert retained.github_token_ciphertext == new_connection.github_token_ciphertext
    assert {:ok, "gho_new"} = Accounts.github_token(retained)
  end

  defp profile(id, login, avatar_url \\ nil) do
    %{
      github_id: id,
      github_login: login,
      github_avatar_url: avatar_url || "https://avatars.githubusercontent.com/u/#{id}?v=4"
    }
  end

  describe "operator identity" do
    test "the owner is an operator no matter what the environment configures" do
      original = Application.get_env(:openagents, :admin_github_ids)
      on_exit(fn -> Application.put_env(:openagents, :admin_github_ids, original) end)

      # `runtime.exs` replaces this list wholesale from an environment
      # variable, so an unset or mistyped value must not be able to lock the
      # owner out of the surface used to fix it.
      Application.put_env(:openagents, :admin_github_ids, [])
      assert 14_167_547 in Accounts.admin_github_ids()

      Application.put_env(:openagents, :admin_github_ids, [999_999])
      ids = Accounts.admin_github_ids()
      assert 14_167_547 in ids
      assert 999_999 in ids
    end

    test "the owner is not an operator while banned" do
      {:ok, owner} = Accounts.upsert_github_user(profile(14_167_547, "AtlantisPleb"))
      assert Accounts.admin?(owner)

      {:ok, banned} = Accounts.ban_user(owner, "manual_abuse_review")
      refute Accounts.admin?(banned)
    end
  end
end