test/openagents/admin_test.exs

58e6347eeb72 · 6 KB

defmodule OpenAgents.AdminTest do
  @moduledoc """
  `OpenAgents.Admin` is the second deliberate exception to IDENTITY-002. These assert
  the shape of that exception: what the projection carries, that it is read-only,
  and that a call with no audio is still a row.
  """

  use OpenAgents.DataCase, async: false
  alias OpenAgents.Accounts
  alias OpenAgents.Admin
  alias OpenAgents.Admin.Call
  alias OpenAgents.Conversations
  alias OpenAgents.Voice
  alias OpenAgents.Voice.Config
  alias OpenAgents.Voice.Recordings

  @webm "audio/webm;codecs=opus"

  test "lists calls across accounts, newest first, with their audio" do
    older = call_for("admin-context-older")
    newer = call_for("admin-context-newer")

    {:ok, _chunk} = Recordings.append_chunk(newer, newer.generation, 1, "opus", @webm)
    {:ok, _closed} = Recordings.finalize(newer, newer.generation, "complete", 5_000)

    # Two different accounts, one list: the exception is the point of the module.
    assert [first, second] = Admin.list_calls()
    assert first.session_id == newer.id
    assert second.session_id == older.id

    assert Call.playable?(first)
    assert first.recording.byte_size == 4
    assert first.recording.chunk_count == 1
    assert first.recording.status == "complete"
    assert first.recording.sealed

    refute Call.playable?(second)
    assert second.recording == nil
    assert Call.absence_reason(second) =~ "No audio uploaded"
  end

  test "the projection carries no private call material" do
    session = call_for("admin-context-private")
    {:ok, _attached} = Voice.attach_provider(session, session.generation, "rtc_admin_context")

    [call] = Admin.list_calls()

    # The struct is the contract, the same way Leaderboard.Entry is. Provider call
    # identity, composed instructions, tool catalogs, and priced cost are absent
    # by construction rather than by remembering not to render them.
    assert Map.keys(Map.from_struct(call)) |> Enum.sort() == [
             :ended_at,
             :failure_code,
             :generation,
             :github_avatar_url,
             :github_login,
             :github_name,
             :model_id,
             :recording,
             :session_id,
             :started_at,
             :status,
             :termination_reason,
             :total_tokens,
             :transcript_item_count,
             :voice_artifact_id
           ]
  end

  test "counts transcript items without reading their content" do
    session = call_for("admin-context-transcript")
    {:ok, session} = Voice.attach_provider(session, session.generation, "rtc_admin_count")

    {:ok, session, _event, :created} =
      Voice.record_provider_event(session, session.generation, %OpenAgents.Voice.ProviderEvent{
        kind: :session_ready,
        provider_event_id: "evt-count-ready",
        payload: %{}
      })

    {:ok, _session, _event, :created} =
      Voice.record_provider_event(session, session.generation, %OpenAgents.Voice.ProviderEvent{
        kind: :user_transcript_final,
        provider_event_id: "evt-count-user",
        payload: %{"item_id" => "item-count", "response_id" => nil, "content" => "secret words"}
      })

    [call] = Admin.list_calls()
    assert call.transcript_item_count == 1
  end

  test "legacy browser-only visitors never appear, because they have no account" do
    {:ok, conversation} = Conversations.ensure_conversation("admin-context-browser-only")
    {:ok, _session} = Voice.admit_session(conversation, enabled_config())

    assert Admin.list_calls() == []
    assert Admin.count_calls() == 0
  end

  test "totals are content-free counters" do
    session = call_for("admin-context-totals")
    {:ok, _chunk} = Recordings.append_chunk(session, session.generation, 1, "opus-bytes", @webm)

    assert Admin.recording_totals() == %{calls: 1, recorded: 1, byte_size: 10}
  end

  test "paging is bounded and cannot be widened past the ceiling" do
    for index <- 1..3, do: call_for("admin-context-page-#{index}")

    assert length(Admin.list_calls(limit: 2)) == 2
    assert length(Admin.list_calls(limit: 2, offset: 2)) == 1
    assert length(Admin.list_calls(limit: 10_000)) == 3
    assert length(Admin.list_calls(limit: -5)) == 3
  end

  test "a token total survives usage that reports no total" do
    session = call_for("admin-context-usage")

    session
    |> Ecto.Changeset.change(%{usage: %{"input_tokens" => 12, "output_tokens" => 8}})
    |> OpenAgents.Repo.update!()

    assert [%Call{total_tokens: 20}] = Admin.list_calls()
  end

  test "get_recording/1 refuses a malformed identifier instead of raising" do
    assert {:error, :not_found} = Admin.get_recording("not-a-uuid")
    assert {:error, :not_found} = Admin.get_recording(Ecto.UUID.generate())
  end

  test "operator access is matched on the immutable GitHub id" do
    user = github_user("admin-context-operator")

    refute Accounts.admin?(user)
    refute Accounts.admin?(nil)

    original = Application.get_env(:openagents, :admin_github_ids, [])

    try do
      Application.put_env(:openagents, :admin_github_ids, [user.github_id])
      assert Accounts.admin?(user)

      # A banned account is never an operator, whatever the allowlist says.
      {:ok, banned} = Accounts.ban_user(user, "policy")
      refute Accounts.admin?(banned)

      # A login string grants nothing.
      Application.put_env(:openagents, :admin_github_ids, [user.github_login])
      refute Accounts.admin?(%{user | status: "active"})
    after
      Application.put_env(:openagents, :admin_github_ids, original)
    end
  end

  defp call_for(key) do
    {:ok, conversation} = Conversations.ensure_conversation(github_user(key))
    {:ok, session} = Voice.admit_session(conversation, enabled_config())
    session
  end

  defp github_user(key) do
    digest = :crypto.hash(:sha256, key)
    github_id = digest |> binary_part(0, 7) |> :binary.decode_unsigned()

    {:ok, user} =
      Accounts.upsert_github_user(%{
        github_id: github_id,
        github_login: "test-#{Base.encode16(digest, case: :lower) |> binary_part(0, 10)}",
        github_avatar_url: "https://avatars.githubusercontent.com/u/#{github_id}?v=4"
      })

    user
  end

  defp enabled_config do
    Config.build!(
      enabled: true,
      architecture: :openai_realtime,
      provider: "openai",
      model: "gpt-realtime-2.1",
      voice: "marin",
      reasoning_effort: "low",
      maximum_session_seconds: 3_000
    )
  end
end